daftarche 0.2.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.
- daftarche-0.2.0/LICENSE +21 -0
- daftarche-0.2.0/PKG-INFO +100 -0
- daftarche-0.2.0/README.md +65 -0
- daftarche-0.2.0/pyproject.toml +55 -0
- daftarche-0.2.0/setup.cfg +4 -0
- daftarche-0.2.0/src/daftarche/__init__.py +8 -0
- daftarche-0.2.0/src/daftarche/__main__.py +7 -0
- daftarche-0.2.0/src/daftarche/app_info.py +35 -0
- daftarche-0.2.0/src/daftarche/backup.py +41 -0
- daftarche-0.2.0/src/daftarche/chart_utils.py +271 -0
- daftarche-0.2.0/src/daftarche/data/icons/app_icon.png +0 -0
- daftarche-0.2.0/src/daftarche/data/icons/splash.png +0 -0
- daftarche-0.2.0/src/daftarche/db.py +1824 -0
- daftarche-0.2.0/src/daftarche/main.py +135 -0
- daftarche-0.2.0/src/daftarche/paths.py +46 -0
- daftarche-0.2.0/src/daftarche/style.py +593 -0
- daftarche-0.2.0/src/daftarche/tabs/__init__.py +0 -0
- daftarche-0.2.0/src/daftarche/tabs/accounts.py +971 -0
- daftarche-0.2.0/src/daftarche/tabs/charts.py +986 -0
- daftarche-0.2.0/src/daftarche/tabs/dashboard.py +709 -0
- daftarche-0.2.0/src/daftarche/tabs/import_tab.py +1069 -0
- daftarche-0.2.0/src/daftarche/tabs/manage.py +1065 -0
- daftarche-0.2.0/src/daftarche/tabs/transactions.py +1636 -0
- daftarche-0.2.0/src/daftarche/widgets.py +1131 -0
- daftarche-0.2.0/src/daftarche/window.py +349 -0
- daftarche-0.2.0/src/daftarche.egg-info/PKG-INFO +100 -0
- daftarche-0.2.0/src/daftarche.egg-info/SOURCES.txt +33 -0
- daftarche-0.2.0/src/daftarche.egg-info/dependency_links.txt +1 -0
- daftarche-0.2.0/src/daftarche.egg-info/entry_points.txt +2 -0
- daftarche-0.2.0/src/daftarche.egg-info/requires.txt +14 -0
- daftarche-0.2.0/src/daftarche.egg-info/top_level.txt +1 -0
- daftarche-0.2.0/tests/test_backup.py +68 -0
- daftarche-0.2.0/tests/test_cli.py +27 -0
- daftarche-0.2.0/tests/test_db.py +69 -0
- daftarche-0.2.0/tests/test_qfx.py +42 -0
daftarche-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vahid Anari
|
|
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.
|
daftarche-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: daftarche
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Daftarche: personal bookkeeping for bank transactions imported from QFX and Excel statements
|
|
5
|
+
Author-email: Vahid Anari <vahid.anari8@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/vahid-anari/daftarche
|
|
8
|
+
Project-URL: Source, https://github.com/vahid-anari/daftarche
|
|
9
|
+
Project-URL: Issues, https://github.com/vahid-anari/daftarche/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/vahid-anari/daftarche/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: personal finance,bookkeeping,budget,QFX,OFX,CIBC,PySide6
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: MacOS X
|
|
14
|
+
Classifier: Environment :: X11 Applications :: Qt
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: PySide6>=6.6
|
|
23
|
+
Requires-Dist: pandas>=2.0
|
|
24
|
+
Requires-Dist: openpyxl>=3.1
|
|
25
|
+
Requires-Dist: sciwidgets~=0.11.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
28
|
+
Requires-Dist: ruff; extra == "dev"
|
|
29
|
+
Requires-Dist: build; extra == "dev"
|
|
30
|
+
Requires-Dist: twine; extra == "dev"
|
|
31
|
+
Provides-Extra: icon
|
|
32
|
+
Requires-Dist: Pillow; extra == "icon"
|
|
33
|
+
Requires-Dist: numpy; extra == "icon"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# Daftarche
|
|
37
|
+
|
|
38
|
+
*Daftarche* (دفترچه, "little account book") is a personal bookkeeping desktop app —
|
|
39
|
+
PySide6 + SQLite. It imports CIBC QFX/OFX statements (and historical Excel files),
|
|
40
|
+
tracks income, expenses and transfers across accounts, with dashboards, charts,
|
|
41
|
+
categories, tags and transfer matching.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```zsh
|
|
46
|
+
pip install -e .
|
|
47
|
+
daftarche # run
|
|
48
|
+
daftarche install-app # add to Applications / Dock
|
|
49
|
+
daftarche update # update from PyPI (asks first)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Data
|
|
53
|
+
|
|
54
|
+
Nothing personal lives in this repository. The app keeps its files in
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
~/Library/Application Support/Daftarche/
|
|
58
|
+
daftarche.db the database
|
|
59
|
+
config.json theme and view settings
|
|
60
|
+
backups/daftarche-last.db written at every start
|
|
61
|
+
backups/daftarche-YYYY-MM.db first start of each month; the last 12 are kept
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Set `DAFTARCHE_HOME=/some/folder` to use another location (e.g. a test copy).
|
|
65
|
+
Backup / Restore in the app's top bar save and load a copy of the database.
|
|
66
|
+
|
|
67
|
+
## Layout
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
src/daftarche/
|
|
71
|
+
main.py command-line entry point
|
|
72
|
+
window.py main window, Settings dialog
|
|
73
|
+
paths.py data locations
|
|
74
|
+
backup.py automatic backups
|
|
75
|
+
db.py SQLite layer (all queries live here)
|
|
76
|
+
style.py stylesheet + settings
|
|
77
|
+
widgets.py shared widgets
|
|
78
|
+
chart_utils.py QChart helpers
|
|
79
|
+
tabs/ dashboard, transactions, charts, accounts, import, manage
|
|
80
|
+
tools/make_icon.py draws data/icons/app_icon.png and splash.png
|
|
81
|
+
tests/ pytest suite (synthetic data only)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Development
|
|
85
|
+
|
|
86
|
+
```zsh
|
|
87
|
+
pip install -e ".[dev]"
|
|
88
|
+
pytest
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Release
|
|
92
|
+
|
|
93
|
+
1. Bump `version` in `pyproject.toml` and add a `## X.Y.Z — date` section to `CHANGELOG.md`.
|
|
94
|
+
2. Commit, then `git tag vX.Y.Z && git push --tags` — the release workflow publishes
|
|
95
|
+
the GitHub Release with that changelog section.
|
|
96
|
+
3. `python -m build && twine upload dist/*` — PyPI.
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Daftarche
|
|
2
|
+
|
|
3
|
+
*Daftarche* (دفترچه, "little account book") is a personal bookkeeping desktop app —
|
|
4
|
+
PySide6 + SQLite. It imports CIBC QFX/OFX statements (and historical Excel files),
|
|
5
|
+
tracks income, expenses and transfers across accounts, with dashboards, charts,
|
|
6
|
+
categories, tags and transfer matching.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```zsh
|
|
11
|
+
pip install -e .
|
|
12
|
+
daftarche # run
|
|
13
|
+
daftarche install-app # add to Applications / Dock
|
|
14
|
+
daftarche update # update from PyPI (asks first)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Data
|
|
18
|
+
|
|
19
|
+
Nothing personal lives in this repository. The app keeps its files in
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
~/Library/Application Support/Daftarche/
|
|
23
|
+
daftarche.db the database
|
|
24
|
+
config.json theme and view settings
|
|
25
|
+
backups/daftarche-last.db written at every start
|
|
26
|
+
backups/daftarche-YYYY-MM.db first start of each month; the last 12 are kept
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Set `DAFTARCHE_HOME=/some/folder` to use another location (e.g. a test copy).
|
|
30
|
+
Backup / Restore in the app's top bar save and load a copy of the database.
|
|
31
|
+
|
|
32
|
+
## Layout
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
src/daftarche/
|
|
36
|
+
main.py command-line entry point
|
|
37
|
+
window.py main window, Settings dialog
|
|
38
|
+
paths.py data locations
|
|
39
|
+
backup.py automatic backups
|
|
40
|
+
db.py SQLite layer (all queries live here)
|
|
41
|
+
style.py stylesheet + settings
|
|
42
|
+
widgets.py shared widgets
|
|
43
|
+
chart_utils.py QChart helpers
|
|
44
|
+
tabs/ dashboard, transactions, charts, accounts, import, manage
|
|
45
|
+
tools/make_icon.py draws data/icons/app_icon.png and splash.png
|
|
46
|
+
tests/ pytest suite (synthetic data only)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Development
|
|
50
|
+
|
|
51
|
+
```zsh
|
|
52
|
+
pip install -e ".[dev]"
|
|
53
|
+
pytest
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Release
|
|
57
|
+
|
|
58
|
+
1. Bump `version` in `pyproject.toml` and add a `## X.Y.Z — date` section to `CHANGELOG.md`.
|
|
59
|
+
2. Commit, then `git tag vX.Y.Z && git push --tags` — the release workflow publishes
|
|
60
|
+
the GitHub Release with that changelog section.
|
|
61
|
+
3. `python -m build && twine upload dist/*` — PyPI.
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "daftarche"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Daftarche: personal bookkeeping for bank transactions imported from QFX and Excel statements"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Vahid Anari", email = "vahid.anari8@gmail.com" }]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
keywords = ["personal finance", "bookkeeping", "budget", "QFX", "OFX", "CIBC", "PySide6"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: MacOS X",
|
|
18
|
+
"Environment :: X11 Applications :: Qt",
|
|
19
|
+
"Intended Audience :: End Users/Desktop",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: Office/Business :: Financial :: Accounting",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"PySide6>=6.6",
|
|
26
|
+
"pandas>=2.0",
|
|
27
|
+
"openpyxl>=3.1",
|
|
28
|
+
"sciwidgets~=0.11.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest>=7", "ruff", "build", "twine"]
|
|
33
|
+
icon = ["Pillow", "numpy"]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
daftarche = "daftarche.main:main"
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/vahid-anari/daftarche"
|
|
40
|
+
Source = "https://github.com/vahid-anari/daftarche"
|
|
41
|
+
Issues = "https://github.com/vahid-anari/daftarche/issues"
|
|
42
|
+
Changelog = "https://github.com/vahid-anari/daftarche/blob/main/CHANGELOG.md"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.packages.find]
|
|
45
|
+
where = ["src"]
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.package-data]
|
|
48
|
+
daftarche = ["data/icons/*.png"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
line-length = 120
|
|
55
|
+
target-version = "py310"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Daftarche: personal bookkeeping desktop app (PySide6 + SQLite)."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("daftarche")
|
|
7
|
+
except PackageNotFoundError: # running from a checkout without `pip install -e .`
|
|
8
|
+
__version__ = "0.0.0"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Daftarche as an app: its name, and the description used by the launcher and the update check."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from sciwidgets.app import AppInfo
|
|
6
|
+
|
|
7
|
+
from daftarche import __version__
|
|
8
|
+
|
|
9
|
+
APP_NAME = "Daftarche"
|
|
10
|
+
"""Name shown to the user (window, splash, launcher)."""
|
|
11
|
+
|
|
12
|
+
APP_VERSION = __version__
|
|
13
|
+
|
|
14
|
+
ICONS = Path(__file__).resolve().parent / "data" / "icons"
|
|
15
|
+
APP_IMAGE = ICONS / "splash.png"
|
|
16
|
+
"""Background of the splash screen (drawn by tools/make_icon.py)."""
|
|
17
|
+
|
|
18
|
+
DIST_NAME = "daftarche"
|
|
19
|
+
"""Name of the package (PyPI, once public)."""
|
|
20
|
+
|
|
21
|
+
APP = AppInfo(
|
|
22
|
+
name=APP_NAME,
|
|
23
|
+
version=APP_VERSION,
|
|
24
|
+
dist_name=DIST_NAME,
|
|
25
|
+
module="daftarche",
|
|
26
|
+
command="daftarche",
|
|
27
|
+
github_repo="vahid-anari/daftarche",
|
|
28
|
+
icon=ICONS / "app_icon.png",
|
|
29
|
+
bundle_id="io.github.vahid-anari.daftarche",
|
|
30
|
+
linux_id="daftarche",
|
|
31
|
+
marker="Daftarche",
|
|
32
|
+
comment="Personal bookkeeping: bank transactions from QFX and Excel statements",
|
|
33
|
+
categories="Office;Finance;",
|
|
34
|
+
)
|
|
35
|
+
"""Daftarche for ``sciwidgets.app`` (launcher, update check)."""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Automatic backups of the database, made at every start.
|
|
2
|
+
|
|
3
|
+
backups/daftarche-last.db overwritten at every start (undo the last session)
|
|
4
|
+
backups/daftarche-2026-10.db first start of each month; never overwritten
|
|
5
|
+
|
|
6
|
+
Only the newest KEEP_MONTHS monthly snapshots are kept. Copies are made with
|
|
7
|
+
SQLite's backup API, so they are consistent even while the database is open.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
from datetime import date
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from daftarche.paths import BACKUP_DIR
|
|
15
|
+
|
|
16
|
+
KEEP_MONTHS = 12
|
|
17
|
+
LAST_BACKUP = BACKUP_DIR / "daftarche-last.db"
|
|
18
|
+
MONTHLY_GLOB = "daftarche-????-??.db"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def copy_db(conn: sqlite3.Connection, destination: Path | str) -> None:
|
|
22
|
+
"""Write a consistent copy of the open database to ``destination``."""
|
|
23
|
+
destination = Path(destination)
|
|
24
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
with sqlite3.connect(destination) as target:
|
|
26
|
+
conn.backup(target)
|
|
27
|
+
target.close()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def startup_backup(conn: sqlite3.Connection, today: date | None = None) -> list[Path]:
|
|
31
|
+
"""The backups made at start-up; returns the files written."""
|
|
32
|
+
today = today or date.today()
|
|
33
|
+
written = [LAST_BACKUP]
|
|
34
|
+
copy_db(conn, LAST_BACKUP)
|
|
35
|
+
monthly = BACKUP_DIR / f"daftarche-{today:%Y-%m}.db"
|
|
36
|
+
if not monthly.exists():
|
|
37
|
+
copy_db(conn, monthly)
|
|
38
|
+
written.append(monthly)
|
|
39
|
+
for old in sorted(BACKUP_DIR.glob(MONTHLY_GLOB))[:-KEEP_MONTHS]:
|
|
40
|
+
old.unlink()
|
|
41
|
+
return written
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
chart_utils.py — Shared chart styling helpers.
|
|
3
|
+
|
|
4
|
+
Key fixes:
|
|
5
|
+
- Qt QValueAxis uses C printf format: "$%.0f"
|
|
6
|
+
- Thousands separator: chart.setLocale(en_US) + chart.setLocalizeNumbers(True)
|
|
7
|
+
- X-axis label rotation: setLabelsAngle(-45) + setTruncateLabels(False)
|
|
8
|
+
"""
|
|
9
|
+
from PySide6.QtCharts import QValueAxis, QBarCategoryAxis, QChart, QLegend, QChartView
|
|
10
|
+
from PySide6.QtGui import QColor, QBrush, QFont, QPen, QPainter
|
|
11
|
+
from PySide6.QtCore import Qt, QLocale, QMargins, QEvent, Signal, QPointF, QRectF
|
|
12
|
+
from PySide6.QtWidgets import QLabel, QWidget
|
|
13
|
+
|
|
14
|
+
from daftarche.style import get_settings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HoverChartView(QChartView):
|
|
18
|
+
mouseMoved = Signal(object)
|
|
19
|
+
mouseLeft = Signal()
|
|
20
|
+
|
|
21
|
+
def __init__(self, parent=None):
|
|
22
|
+
super().__init__(parent)
|
|
23
|
+
|
|
24
|
+
self.setRenderHint(QPainter.Antialiasing)
|
|
25
|
+
self.setMouseTracking(True)
|
|
26
|
+
self.viewport().setMouseTracking(True)
|
|
27
|
+
self.viewport().installEventFilter(self)
|
|
28
|
+
|
|
29
|
+
self._hover_label = QLabel("", self.viewport())
|
|
30
|
+
self._hover_label.setAlignment(Qt.AlignCenter)
|
|
31
|
+
self._hover_label.setWordWrap(True)
|
|
32
|
+
self._hover_label.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
|
33
|
+
self._hover_label.hide()
|
|
34
|
+
|
|
35
|
+
self._last_mouse_pos = None
|
|
36
|
+
self._style_hover_label()
|
|
37
|
+
|
|
38
|
+
def eventFilter(self, obj, event):
|
|
39
|
+
if obj is self.viewport():
|
|
40
|
+
if event.type() == QEvent.MouseMove:
|
|
41
|
+
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
|
42
|
+
self._last_mouse_pos = pos
|
|
43
|
+
self.mouseMoved.emit(pos)
|
|
44
|
+
|
|
45
|
+
elif event.type() == QEvent.Leave:
|
|
46
|
+
self._last_mouse_pos = None
|
|
47
|
+
self.hide_hover()
|
|
48
|
+
self.mouseLeft.emit()
|
|
49
|
+
|
|
50
|
+
return super().eventFilter(obj, event)
|
|
51
|
+
|
|
52
|
+
def last_mouse_pos(self):
|
|
53
|
+
return self._last_mouse_pos
|
|
54
|
+
|
|
55
|
+
def refresh_hover_style(self):
|
|
56
|
+
self._style_hover_label()
|
|
57
|
+
|
|
58
|
+
def _style_hover_label(self, border_color=None):
|
|
59
|
+
s = get_settings()
|
|
60
|
+
border = border_color or "rgba(255,255,255,40)"
|
|
61
|
+
|
|
62
|
+
self._hover_label.setStyleSheet(
|
|
63
|
+
f"color:{s.get('text')}; background: rgba(20,20,30,200);"
|
|
64
|
+
f" border: 1px solid {border};"
|
|
65
|
+
f" border-radius: 6px; padding: 4px 10px;"
|
|
66
|
+
f" font-size: {s.get('font_size')}pt;"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def show_hover(self, text, viewport_pos=None, border_color=None):
|
|
70
|
+
if not text:
|
|
71
|
+
self.hide_hover()
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
self._style_hover_label(border_color)
|
|
75
|
+
|
|
76
|
+
self._hover_label.setText(text)
|
|
77
|
+
self._hover_label.adjustSize()
|
|
78
|
+
self._hover_label.raise_()
|
|
79
|
+
|
|
80
|
+
if viewport_pos is None:
|
|
81
|
+
viewport_pos = self._last_mouse_pos
|
|
82
|
+
|
|
83
|
+
if viewport_pos is not None:
|
|
84
|
+
vp = self.viewport()
|
|
85
|
+
w = self._hover_label.width()
|
|
86
|
+
h = self._hover_label.height()
|
|
87
|
+
|
|
88
|
+
x = max(4, min(viewport_pos.x() + 12, vp.width() - w - 4))
|
|
89
|
+
y = max(4, min(viewport_pos.y() - h - 8, vp.height() - h - 4))
|
|
90
|
+
|
|
91
|
+
self._hover_label.move(x, y)
|
|
92
|
+
|
|
93
|
+
self._hover_label.show()
|
|
94
|
+
|
|
95
|
+
def hide_hover(self):
|
|
96
|
+
self._hover_label.hide()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# _StackedOverlay — paints per-month sorted stacked bars over QChartView
|
|
100
|
+
class StackedOverlay(QWidget):
|
|
101
|
+
|
|
102
|
+
def __init__(self, chart_view, paint_data, on_hover_cb=None):
|
|
103
|
+
super().__init__(chart_view.viewport())
|
|
104
|
+
self._chart_view = chart_view
|
|
105
|
+
self._paint_data = paint_data
|
|
106
|
+
self._on_hover_cb = on_hover_cb
|
|
107
|
+
self._hovered = None
|
|
108
|
+
self.setAttribute(Qt.WA_TransparentForMouseEvents, False)
|
|
109
|
+
self.setMouseTracking(True)
|
|
110
|
+
self.resize(chart_view.viewport().size())
|
|
111
|
+
chart_view.viewport().installEventFilter(self)
|
|
112
|
+
|
|
113
|
+
def eventFilter(self, obj, event):
|
|
114
|
+
if obj is self._chart_view.viewport() and event.type() == QEvent.Resize:
|
|
115
|
+
self.resize(obj.size())
|
|
116
|
+
self.update()
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
def _bar_rects(self):
|
|
120
|
+
chart = self._chart_view.chart()
|
|
121
|
+
pd = self._paint_data
|
|
122
|
+
periods = pd["all_periods"]
|
|
123
|
+
order = pd["per_month_order"]
|
|
124
|
+
data = pd["data"]
|
|
125
|
+
n = len(periods)
|
|
126
|
+
if n == 0 or not chart:
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
rects = []
|
|
130
|
+
for pi, period in enumerate(periods):
|
|
131
|
+
cats_sorted = order[pi]
|
|
132
|
+
baseline = 0.0
|
|
133
|
+
for cat in cats_sorted:
|
|
134
|
+
val = data[period].get(cat, 0)
|
|
135
|
+
if val <= 0:
|
|
136
|
+
continue
|
|
137
|
+
top_scene = chart.mapToScene(chart.mapToPosition(QPointF(pi, baseline + val)))
|
|
138
|
+
bottom_scene = chart.mapToScene(chart.mapToPosition(QPointF(pi, baseline)))
|
|
139
|
+
top_vp = self._chart_view.mapFromScene(top_scene)
|
|
140
|
+
bottom_vp = self._chart_view.mapFromScene(bottom_scene)
|
|
141
|
+
|
|
142
|
+
if n > 1:
|
|
143
|
+
left_scene = chart.mapToScene(chart.mapToPosition(QPointF(pi - 0.35, baseline)))
|
|
144
|
+
right_scene = chart.mapToScene(chart.mapToPosition(QPointF(pi + 0.35, baseline)))
|
|
145
|
+
left_vp = self._chart_view.mapFromScene(left_scene)
|
|
146
|
+
right_vp = self._chart_view.mapFromScene(right_scene)
|
|
147
|
+
bar_left = left_vp.x()
|
|
148
|
+
bar_right = right_vp.x()
|
|
149
|
+
else:
|
|
150
|
+
plot = chart.plotArea()
|
|
151
|
+
bar_left = plot.left() + plot.width() * 0.25
|
|
152
|
+
bar_right = plot.right() - plot.width() * 0.25
|
|
153
|
+
|
|
154
|
+
rect = QRectF(
|
|
155
|
+
bar_left,
|
|
156
|
+
top_vp.y(),
|
|
157
|
+
bar_right - bar_left,
|
|
158
|
+
abs(bottom_vp.y() - top_vp.y()),
|
|
159
|
+
)
|
|
160
|
+
rects.append((rect, pi, cat, val, baseline))
|
|
161
|
+
baseline += val
|
|
162
|
+
return rects
|
|
163
|
+
|
|
164
|
+
def paintEvent(self, event):
|
|
165
|
+
painter = QPainter(self)
|
|
166
|
+
painter.setRenderHint(QPainter.Antialiasing, False)
|
|
167
|
+
colors = self._paint_data.get("cat_colors", {})
|
|
168
|
+
|
|
169
|
+
for rect, pi, cat, val, baseline in self._bar_rects():
|
|
170
|
+
color = QColor(colors.get(cat, "#888888"))
|
|
171
|
+
painter.fillRect(rect, color)
|
|
172
|
+
pen = QPen(QColor(255, 255, 255, 255))
|
|
173
|
+
pen.setWidth(1)
|
|
174
|
+
painter.setPen(pen)
|
|
175
|
+
painter.drawRect(rect)
|
|
176
|
+
|
|
177
|
+
painter.end()
|
|
178
|
+
|
|
179
|
+
def mouseMoveEvent(self, event):
|
|
180
|
+
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
|
181
|
+
hit = None
|
|
182
|
+
for rect, pi, cat, val, baseline in self._bar_rects():
|
|
183
|
+
if rect.contains(pos):
|
|
184
|
+
hit = (pi, cat, val, baseline, pos)
|
|
185
|
+
break
|
|
186
|
+
if hit != self._hovered:
|
|
187
|
+
self._hovered = hit
|
|
188
|
+
if self._on_hover_cb:
|
|
189
|
+
self._on_hover_cb(hit)
|
|
190
|
+
|
|
191
|
+
def leaveEvent(self, event):
|
|
192
|
+
self._hovered = None
|
|
193
|
+
if self._on_hover_cb:
|
|
194
|
+
self._on_hover_cb(None)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def make_chart(title="", animated=True):
|
|
198
|
+
s = get_settings()
|
|
199
|
+
chart = QChart()
|
|
200
|
+
if title:
|
|
201
|
+
chart.setTitle(title)
|
|
202
|
+
chart.setTitleFont(QFont(s.get("font_family"), s.get("font_size") + 1, QFont.Bold))
|
|
203
|
+
chart.setTitleBrush(QBrush(QColor(s.get("text"))))
|
|
204
|
+
|
|
205
|
+
chart.setBackgroundBrush(QBrush(QColor(s.get("bg_surface"))))
|
|
206
|
+
|
|
207
|
+
if animated:
|
|
208
|
+
chart.setAnimationOptions(QChart.SeriesAnimations)
|
|
209
|
+
else:
|
|
210
|
+
chart.setAnimationOptions(QChart.NoAnimation)
|
|
211
|
+
|
|
212
|
+
chart.legend().setLabelColor(QColor(s.get("text")))
|
|
213
|
+
chart.legend().setAlignment(Qt.AlignRight)
|
|
214
|
+
chart.legend().setBackgroundVisible(False)
|
|
215
|
+
chart.legend().setFont(QFont(s.get("font_family"), s.get("font_size")))
|
|
216
|
+
chart.legend().setMarkerShape(QLegend.MarkerShapeRectangle)
|
|
217
|
+
chart.setMargins(QMargins(10, 10, 10, 10))
|
|
218
|
+
|
|
219
|
+
locale = QLocale(QLocale.Language.English, QLocale.Country.UnitedStates)
|
|
220
|
+
chart.setLocale(locale)
|
|
221
|
+
chart.setLocalizeNumbers(True)
|
|
222
|
+
|
|
223
|
+
return chart
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def make_y_axis(min_val=None, max_val=None):
|
|
227
|
+
"""Y axis with dollar values, proper Qt format string."""
|
|
228
|
+
s = get_settings()
|
|
229
|
+
ax = QValueAxis()
|
|
230
|
+
ax.setLabelFormat("$%.0f")
|
|
231
|
+
ax.setLabelsColor(QColor(s.get("text")))
|
|
232
|
+
ax.setGridLineColor(QColor(s.get("border")))
|
|
233
|
+
ax.setLinePen(QPen(QColor(s.get("border"))))
|
|
234
|
+
ax.setTitleBrush(QBrush(QColor(s.get("text_dim"))))
|
|
235
|
+
ax.setLabelsFont(QFont(s.get("font_family"), s.get("font_size") - 1))
|
|
236
|
+
if min_val is not None:
|
|
237
|
+
ax.setMin(min_val)
|
|
238
|
+
if max_val is not None:
|
|
239
|
+
ax.setMax(max_val)
|
|
240
|
+
return ax
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def make_x_axis(labels, rotate=None):
|
|
244
|
+
"""X axis with category labels, rotated for readability."""
|
|
245
|
+
s = get_settings()
|
|
246
|
+
ax = QBarCategoryAxis()
|
|
247
|
+
ax.append(labels)
|
|
248
|
+
ax.setLabelsColor(QColor(s.get("text")))
|
|
249
|
+
ax.setGridLineColor(QColor(s.get("border")))
|
|
250
|
+
ax.setLinePen(QPen(QColor(s.get("border"))))
|
|
251
|
+
ax.setLabelsFont(QFont(s.get("font_family"), s.get("font_size") - 1))
|
|
252
|
+
effective_rotate = rotate if rotate is not None else (-45 if len(labels) > 12 else 0)
|
|
253
|
+
if effective_rotate:
|
|
254
|
+
ax.setLabelsAngle(effective_rotate)
|
|
255
|
+
ax.setTruncateLabels(False)
|
|
256
|
+
return ax
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def style_chart_view(view, bordered=True):
|
|
260
|
+
s = get_settings()
|
|
261
|
+
if bordered:
|
|
262
|
+
view.setStyleSheet(
|
|
263
|
+
f"background:{s.get('bg_surface')}; "
|
|
264
|
+
f"border:1px solid {s.get('border')}; "
|
|
265
|
+
f"border-radius:8px;"
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
view.setStyleSheet(
|
|
269
|
+
f"background: transparent; "
|
|
270
|
+
f"border: none;"
|
|
271
|
+
)
|
|
Binary file
|
|
Binary file
|