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.
Files changed (35) hide show
  1. daftarche-0.2.0/LICENSE +21 -0
  2. daftarche-0.2.0/PKG-INFO +100 -0
  3. daftarche-0.2.0/README.md +65 -0
  4. daftarche-0.2.0/pyproject.toml +55 -0
  5. daftarche-0.2.0/setup.cfg +4 -0
  6. daftarche-0.2.0/src/daftarche/__init__.py +8 -0
  7. daftarche-0.2.0/src/daftarche/__main__.py +7 -0
  8. daftarche-0.2.0/src/daftarche/app_info.py +35 -0
  9. daftarche-0.2.0/src/daftarche/backup.py +41 -0
  10. daftarche-0.2.0/src/daftarche/chart_utils.py +271 -0
  11. daftarche-0.2.0/src/daftarche/data/icons/app_icon.png +0 -0
  12. daftarche-0.2.0/src/daftarche/data/icons/splash.png +0 -0
  13. daftarche-0.2.0/src/daftarche/db.py +1824 -0
  14. daftarche-0.2.0/src/daftarche/main.py +135 -0
  15. daftarche-0.2.0/src/daftarche/paths.py +46 -0
  16. daftarche-0.2.0/src/daftarche/style.py +593 -0
  17. daftarche-0.2.0/src/daftarche/tabs/__init__.py +0 -0
  18. daftarche-0.2.0/src/daftarche/tabs/accounts.py +971 -0
  19. daftarche-0.2.0/src/daftarche/tabs/charts.py +986 -0
  20. daftarche-0.2.0/src/daftarche/tabs/dashboard.py +709 -0
  21. daftarche-0.2.0/src/daftarche/tabs/import_tab.py +1069 -0
  22. daftarche-0.2.0/src/daftarche/tabs/manage.py +1065 -0
  23. daftarche-0.2.0/src/daftarche/tabs/transactions.py +1636 -0
  24. daftarche-0.2.0/src/daftarche/widgets.py +1131 -0
  25. daftarche-0.2.0/src/daftarche/window.py +349 -0
  26. daftarche-0.2.0/src/daftarche.egg-info/PKG-INFO +100 -0
  27. daftarche-0.2.0/src/daftarche.egg-info/SOURCES.txt +33 -0
  28. daftarche-0.2.0/src/daftarche.egg-info/dependency_links.txt +1 -0
  29. daftarche-0.2.0/src/daftarche.egg-info/entry_points.txt +2 -0
  30. daftarche-0.2.0/src/daftarche.egg-info/requires.txt +14 -0
  31. daftarche-0.2.0/src/daftarche.egg-info/top_level.txt +1 -0
  32. daftarche-0.2.0/tests/test_backup.py +68 -0
  33. daftarche-0.2.0/tests/test_cli.py +27 -0
  34. daftarche-0.2.0/tests/test_db.py +69 -0
  35. daftarche-0.2.0/tests/test_qfx.py +42 -0
@@ -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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,7 @@
1
+ """Run with ``python -m daftarche`` (used by the app launcher)."""
2
+
3
+ import sys
4
+
5
+ from daftarche.main import main
6
+
7
+ sys.exit(main())
@@ -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
+ )