glidepath 0.2.0__py3-none-any.whl
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.
- glidepath/__init__.py +3 -0
- glidepath/app/__init__.py +364 -0
- glidepath/app/backtest.py +281 -0
- glidepath/app/charts.py +759 -0
- glidepath/app/copy.py +174 -0
- glidepath/app/display.py +148 -0
- glidepath/app/drawdown.py +436 -0
- glidepath/app/example.py +66 -0
- glidepath/app/exports.py +487 -0
- glidepath/app/files.py +249 -0
- glidepath/app/firstrun.py +114 -0
- glidepath/app/forms.py +1750 -0
- glidepath/app/inspector.py +506 -0
- glidepath/app/labels.py +66 -0
- glidepath/app/montecarlo.py +399 -0
- glidepath/app/plan.py +354 -0
- glidepath/app/retirement.py +446 -0
- glidepath/app/scenarios.py +831 -0
- glidepath/app/shell.py +185 -0
- glidepath/app/tables.py +138 -0
- glidepath/core/__init__.py +390 -0
- glidepath/core/annuities.py +240 -0
- glidepath/core/backtest.py +514 -0
- glidepath/core/comparison.py +278 -0
- glidepath/core/config.py +82 -0
- glidepath/core/contributions.py +337 -0
- glidepath/core/engine.py +2811 -0
- glidepath/core/entities.py +264 -0
- glidepath/core/glide.py +289 -0
- glidepath/core/investments.py +175 -0
- glidepath/core/money.py +107 -0
- glidepath/core/montecarlo.py +609 -0
- glidepath/core/pensions.py +298 -0
- glidepath/core/periods.py +367 -0
- glidepath/core/provenance.py +271 -0
- glidepath/core/randomness.py +128 -0
- glidepath/core/region.py +46 -0
- glidepath/core/reporting.py +231 -0
- glidepath/core/results.py +504 -0
- glidepath/core/retirement.py +291 -0
- glidepath/core/returns.py +312 -0
- glidepath/core/scenarios.py +579 -0
- glidepath/core/state_pension.py +264 -0
- glidepath/core/tax.py +139 -0
- glidepath/core/withdrawals.py +461 -0
- glidepath/core/wrappers.py +278 -0
- glidepath/gui/__init__.py +6 -0
- glidepath/gui/assets/icon_128.png +0 -0
- glidepath/gui/assets/icon_16.png +0 -0
- glidepath/gui/assets/icon_24.png +0 -0
- glidepath/gui/assets/icon_256.png +0 -0
- glidepath/gui/assets/icon_32.png +0 -0
- glidepath/gui/assets/icon_48.png +0 -0
- glidepath/gui/assets/icon_64.png +0 -0
- glidepath/gui/assets/wordmark.png +0 -0
- glidepath/gui/charts.py +829 -0
- glidepath/gui/forms.py +359 -0
- glidepath/gui/inspector.py +186 -0
- glidepath/gui/main.py +51 -0
- glidepath/gui/scenarios.py +402 -0
- glidepath/gui/style.py +376 -0
- glidepath/gui/tableview.py +67 -0
- glidepath/gui/widgets.py +989 -0
- glidepath/persistence/__init__.py +48 -0
- glidepath/persistence/assumptions.py +112 -0
- glidepath/persistence/decode.py +747 -0
- glidepath/persistence/document.py +101 -0
- glidepath/persistence/encode.py +433 -0
- glidepath/persistence/migrations.py +158 -0
- glidepath/persistence/values.py +298 -0
- glidepath/py.typed +0 -0
- glidepath/regions/__init__.py +7 -0
- glidepath/regions/uk/__init__.py +189 -0
- glidepath/regions/uk/ages.py +156 -0
- glidepath/regions/uk/contributions.py +717 -0
- glidepath/regions/uk/data/age_rules.toml +78 -0
- glidepath/regions/uk/data/assumptions_default.toml +170 -0
- glidepath/regions/uk/data/returns_history.toml +150 -0
- glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
- glidepath/regions/uk/extension.py +479 -0
- glidepath/regions/uk/loader.py +704 -0
- glidepath/regions/uk/region.py +160 -0
- glidepath/regions/uk/schema.py +563 -0
- glidepath/regions/uk/state_pension.py +129 -0
- glidepath/regions/uk/tax.py +466 -0
- glidepath/regions/uk/wrappers.py +283 -0
- glidepath/regions/uk/years.py +92 -0
- glidepath-0.2.0.dist-info/METADATA +189 -0
- glidepath-0.2.0.dist-info/RECORD +93 -0
- glidepath-0.2.0.dist-info/WHEEL +4 -0
- glidepath-0.2.0.dist-info/entry_points.txt +3 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE-DATA +28 -0
glidepath/gui/forms.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Facts entry widgets: thin bindings over the form specs (§4.7, 8.2).
|
|
2
|
+
|
|
3
|
+
Every label, hint, and message comes from the app layer's
|
|
4
|
+
:class:`~glidepath.app.FactsFormViewModel`; these widgets only render
|
|
5
|
+
fields, collect raw text back, and forward it to the submit callback.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from PySide6.QtCore import QDate, Qt
|
|
11
|
+
from PySide6.QtWidgets import (
|
|
12
|
+
QCalendarWidget,
|
|
13
|
+
QComboBox,
|
|
14
|
+
QFormLayout,
|
|
15
|
+
QGroupBox,
|
|
16
|
+
QHBoxLayout,
|
|
17
|
+
QLabel,
|
|
18
|
+
QLineEdit,
|
|
19
|
+
QPushButton,
|
|
20
|
+
QScrollArea,
|
|
21
|
+
QStyle,
|
|
22
|
+
QVBoxLayout,
|
|
23
|
+
QWidget,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from glidepath.app import (
|
|
27
|
+
DATE_PICKER_TOOLTIP,
|
|
28
|
+
ENTITY_ID_KEY,
|
|
29
|
+
FactsFormData,
|
|
30
|
+
FactsFormViewModel,
|
|
31
|
+
FieldKind,
|
|
32
|
+
PlanEntityIds,
|
|
33
|
+
SectionSpec,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
38
|
+
|
|
39
|
+
from PySide6.QtGui import QWheelEvent
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ScrollSafeComboBox(QComboBox):
|
|
43
|
+
"""A combo box that scroll gestures pass over instead of spinning.
|
|
44
|
+
|
|
45
|
+
Inside a scrolling form, a stock ``QComboBox`` under the pointer
|
|
46
|
+
swallows wheel events and changes its value — so scrolling the
|
|
47
|
+
page silently edits the plan. This variant only reacts to the
|
|
48
|
+
wheel once the user has clicked into it; otherwise the event
|
|
49
|
+
propagates to the surrounding scroll area.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
53
|
+
"""Take focus by click or tab only, never by wheel-over."""
|
|
54
|
+
super().__init__(parent)
|
|
55
|
+
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
56
|
+
|
|
57
|
+
def wheelEvent(self, event: QWheelEvent) -> None: # noqa: N802
|
|
58
|
+
"""Spin the value only when focused; else let the page scroll."""
|
|
59
|
+
if self.hasFocus():
|
|
60
|
+
super().wheelEvent(event)
|
|
61
|
+
else:
|
|
62
|
+
event.ignore()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DateEntry(QLineEdit):
|
|
66
|
+
"""An ISO date field: typed text first, a calendar popup as assist.
|
|
67
|
+
|
|
68
|
+
A plain line edit (so blank stays honestly blank — the app layer's
|
|
69
|
+
blank-means-today/none semantics survive, `FieldKind.DATE`) with a
|
|
70
|
+
trailing action that pops a calendar; picking a day writes it back
|
|
71
|
+
as ``YYYY-MM-DD`` text, exactly as if typed.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
75
|
+
"""Build the line edit with its calendar action and popup."""
|
|
76
|
+
super().__init__(parent)
|
|
77
|
+
self.calendar = QCalendarWidget(self)
|
|
78
|
+
self.calendar.setWindowFlags(Qt.WindowType.Popup)
|
|
79
|
+
self.calendar.clicked.connect(self._day_picked)
|
|
80
|
+
# clicked covers the mouse; activated (Enter/Return) covers the
|
|
81
|
+
# keyboard — without it the popup can be navigated but never
|
|
82
|
+
# committed by keyboard users.
|
|
83
|
+
self.calendar.activated.connect(self._day_picked)
|
|
84
|
+
self.pick_action = self.addAction(
|
|
85
|
+
self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown),
|
|
86
|
+
QLineEdit.ActionPosition.TrailingPosition,
|
|
87
|
+
)
|
|
88
|
+
self.pick_action.setToolTip(DATE_PICKER_TOOLTIP)
|
|
89
|
+
self.pick_action.triggered.connect(self._open_calendar)
|
|
90
|
+
|
|
91
|
+
def _open_calendar(self) -> None:
|
|
92
|
+
"""Pop the calendar under the field, seeded from its text."""
|
|
93
|
+
current = QDate.fromString(self.text().strip(), Qt.DateFormat.ISODate)
|
|
94
|
+
if current.isValid():
|
|
95
|
+
self.calendar.setSelectedDate(current)
|
|
96
|
+
self.calendar.move(self.mapToGlobal(self.rect().bottomLeft()))
|
|
97
|
+
self.calendar.show()
|
|
98
|
+
|
|
99
|
+
def _day_picked(self, day: QDate) -> None:
|
|
100
|
+
"""Write the picked day back as ISO text and close the popup."""
|
|
101
|
+
self.setText(day.toString(Qt.DateFormat.ISODate))
|
|
102
|
+
self.calendar.hide()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SectionForm(QGroupBox):
|
|
106
|
+
"""One titled group of fields rendered from a :class:`SectionSpec`."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, spec: SectionSpec, parent: QWidget | None = None) -> None:
|
|
109
|
+
"""Render the section's fields as labelled editors."""
|
|
110
|
+
super().__init__(spec.title, parent)
|
|
111
|
+
self._editors: dict[str, QLineEdit | QComboBox] = {}
|
|
112
|
+
layout = QFormLayout(self)
|
|
113
|
+
if spec.description:
|
|
114
|
+
description = QLabel(spec.description, self)
|
|
115
|
+
description.setWordWrap(True)
|
|
116
|
+
layout.addRow(description)
|
|
117
|
+
for field in spec.fields:
|
|
118
|
+
if field.kind is FieldKind.CHOICE:
|
|
119
|
+
combo = ScrollSafeComboBox(self)
|
|
120
|
+
for option in field.choices:
|
|
121
|
+
combo.addItem(option.label, option.value)
|
|
122
|
+
self._editors[field.key] = combo
|
|
123
|
+
layout.addRow(field.label, combo)
|
|
124
|
+
else:
|
|
125
|
+
edit = (
|
|
126
|
+
DateEntry(self) if field.kind is FieldKind.DATE else QLineEdit(self)
|
|
127
|
+
)
|
|
128
|
+
edit.setPlaceholderText(field.hint)
|
|
129
|
+
self._editors[field.key] = edit
|
|
130
|
+
layout.addRow(field.label, edit)
|
|
131
|
+
|
|
132
|
+
def editor(self, key: str) -> QLineEdit | QComboBox:
|
|
133
|
+
"""The editor widget for ``key`` (tests and shells drive it)."""
|
|
134
|
+
return self._editors[key]
|
|
135
|
+
|
|
136
|
+
def set_value(self, key: str, value: str) -> None:
|
|
137
|
+
"""Set one field's raw value programmatically."""
|
|
138
|
+
editor = self._editors[key]
|
|
139
|
+
if isinstance(editor, QComboBox):
|
|
140
|
+
editor.setCurrentIndex(editor.findData(value))
|
|
141
|
+
else:
|
|
142
|
+
editor.setText(value)
|
|
143
|
+
|
|
144
|
+
def values(self) -> dict[str, str]:
|
|
145
|
+
"""The section's raw text, keyed exactly like its spec."""
|
|
146
|
+
collected: dict[str, str] = {}
|
|
147
|
+
for key, editor in self._editors.items():
|
|
148
|
+
if isinstance(editor, QComboBox):
|
|
149
|
+
collected[key] = str(editor.currentData())
|
|
150
|
+
else:
|
|
151
|
+
collected[key] = editor.text()
|
|
152
|
+
return collected
|
|
153
|
+
|
|
154
|
+
def set_values(self, values: Mapping[str, str]) -> None:
|
|
155
|
+
"""Set the given fields; keys absent from ``values`` are untouched."""
|
|
156
|
+
for key, value in values.items():
|
|
157
|
+
self.set_value(key, value)
|
|
158
|
+
|
|
159
|
+
def clear(self) -> None:
|
|
160
|
+
"""Blank every text field and reset every choice to its first option."""
|
|
161
|
+
for editor in self._editors.values():
|
|
162
|
+
if isinstance(editor, QComboBox):
|
|
163
|
+
editor.setCurrentIndex(0)
|
|
164
|
+
else:
|
|
165
|
+
editor.setText("")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class RepeatableSection(QWidget):
|
|
169
|
+
"""A list of :class:`SectionForm` instances with add/remove controls.
|
|
170
|
+
|
|
171
|
+
Each instance carries its entity id opaquely (blank for a row the
|
|
172
|
+
user added), so deleting or reordering rows keeps every surviving
|
|
173
|
+
entity's identity — the id travels with its row, never with its
|
|
174
|
+
position (§4.3).
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def __init__(self, spec: SectionSpec, parent: QWidget | None = None) -> None:
|
|
178
|
+
"""Render the empty list with its add button."""
|
|
179
|
+
super().__init__(parent)
|
|
180
|
+
self._spec = spec
|
|
181
|
+
self._entries: list[QWidget] = []
|
|
182
|
+
self._forms: list[SectionForm] = []
|
|
183
|
+
self._entity_ids: list[str] = []
|
|
184
|
+
layout = QVBoxLayout(self)
|
|
185
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
186
|
+
self._list_layout = QVBoxLayout()
|
|
187
|
+
layout.addLayout(self._list_layout)
|
|
188
|
+
self.add_button = QPushButton(spec.add_label, self)
|
|
189
|
+
self.add_button.clicked.connect(self.add_entry)
|
|
190
|
+
layout.addWidget(self.add_button)
|
|
191
|
+
|
|
192
|
+
def add_entry(self, *, entity_id: str = "") -> SectionForm:
|
|
193
|
+
"""Append one more section instance and return its form."""
|
|
194
|
+
container = QWidget(self)
|
|
195
|
+
container_layout = QVBoxLayout(container)
|
|
196
|
+
container_layout.setContentsMargins(0, 0, 0, 0)
|
|
197
|
+
form = SectionForm(self._spec, container)
|
|
198
|
+
remove = QPushButton(self._spec.remove_label, container)
|
|
199
|
+
remove.clicked.connect(lambda: self._remove_entry(container))
|
|
200
|
+
container_layout.addWidget(form)
|
|
201
|
+
container_layout.addWidget(remove)
|
|
202
|
+
self._list_layout.addWidget(container)
|
|
203
|
+
self._entries.append(container)
|
|
204
|
+
self._forms.append(form)
|
|
205
|
+
self._entity_ids.append(entity_id)
|
|
206
|
+
return form
|
|
207
|
+
|
|
208
|
+
def _remove_entry(self, container: QWidget) -> None:
|
|
209
|
+
"""Drop one section instance — and its entity id — from the list."""
|
|
210
|
+
index = self._entries.index(container)
|
|
211
|
+
del self._entries[index]
|
|
212
|
+
del self._forms[index]
|
|
213
|
+
del self._entity_ids[index]
|
|
214
|
+
self._list_layout.removeWidget(container)
|
|
215
|
+
container.deleteLater()
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def forms(self) -> tuple[SectionForm, ...]:
|
|
219
|
+
"""The live section instances, in display order."""
|
|
220
|
+
return tuple(self._forms)
|
|
221
|
+
|
|
222
|
+
def values_list(self) -> tuple[dict[str, str], ...]:
|
|
223
|
+
"""Raw text for every instance, its entity id included, in order."""
|
|
224
|
+
return tuple(
|
|
225
|
+
{ENTITY_ID_KEY: entity_id, **form.values()}
|
|
226
|
+
for entity_id, form in zip(self._entity_ids, self._forms, strict=True)
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def set_values_list(self, values_list: Sequence[Mapping[str, str]]) -> None:
|
|
230
|
+
"""Replace every instance with one per entry of ``values_list``."""
|
|
231
|
+
self.clear()
|
|
232
|
+
for values in values_list:
|
|
233
|
+
form = self.add_entry(entity_id=values.get(ENTITY_ID_KEY, ""))
|
|
234
|
+
form.set_values(
|
|
235
|
+
{key: value for key, value in values.items() if key != ENTITY_ID_KEY}
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def set_entity_ids(self, entity_ids: Sequence[str]) -> None:
|
|
239
|
+
"""Re-seed every row's entity id, e.g. after a successful save."""
|
|
240
|
+
if len(entity_ids) != len(self._forms):
|
|
241
|
+
msg = "one entity id per section instance required"
|
|
242
|
+
raise ValueError(msg)
|
|
243
|
+
self._entity_ids = list(entity_ids)
|
|
244
|
+
|
|
245
|
+
def clear(self) -> None:
|
|
246
|
+
"""Drop every section instance."""
|
|
247
|
+
for container in tuple(self._entries):
|
|
248
|
+
self._remove_entry(container)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class FactsEntryPane(QWidget):
|
|
252
|
+
"""The facts tab: sections, submit and clear buttons, a status line."""
|
|
253
|
+
|
|
254
|
+
def __init__(
|
|
255
|
+
self,
|
|
256
|
+
view_model: FactsFormViewModel,
|
|
257
|
+
on_submit: Callable[[FactsFormData], str],
|
|
258
|
+
on_clear: Callable[[], str],
|
|
259
|
+
parent: QWidget | None = None,
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Render the form and wire the submit and clear callbacks."""
|
|
262
|
+
super().__init__(parent)
|
|
263
|
+
self._on_submit = on_submit
|
|
264
|
+
self._on_clear = on_clear
|
|
265
|
+
|
|
266
|
+
intro = QLabel(view_model.intro, self)
|
|
267
|
+
intro.setWordWrap(True)
|
|
268
|
+
self.person_form = SectionForm(view_model.person)
|
|
269
|
+
self.spending_form = SectionForm(view_model.spending)
|
|
270
|
+
self.state_pension_form = SectionForm(view_model.state_pension)
|
|
271
|
+
self.wrappers = RepeatableSection(view_model.wrapper)
|
|
272
|
+
self.db_pensions = RepeatableSection(view_model.db_pension)
|
|
273
|
+
self.annuity_purchases = RepeatableSection(view_model.annuity_purchase)
|
|
274
|
+
self.submit_button = QPushButton(view_model.submit_label, self)
|
|
275
|
+
self.submit_button.setObjectName("primaryButton")
|
|
276
|
+
self.submit_button.clicked.connect(self.submit)
|
|
277
|
+
self.clear_button = QPushButton(view_model.clear_label, self)
|
|
278
|
+
self.clear_button.clicked.connect(self.clear)
|
|
279
|
+
self.status_label = QLabel("", self)
|
|
280
|
+
self.status_label.setWordWrap(True)
|
|
281
|
+
|
|
282
|
+
content = QWidget(self)
|
|
283
|
+
content_layout = QVBoxLayout(content)
|
|
284
|
+
content_layout.addWidget(intro)
|
|
285
|
+
content_layout.addWidget(self.person_form)
|
|
286
|
+
content_layout.addWidget(self.spending_form)
|
|
287
|
+
content_layout.addWidget(self.state_pension_form)
|
|
288
|
+
content_layout.addWidget(self.wrappers)
|
|
289
|
+
content_layout.addWidget(self.db_pensions)
|
|
290
|
+
content_layout.addWidget(self.annuity_purchases)
|
|
291
|
+
content_layout.addStretch()
|
|
292
|
+
|
|
293
|
+
scroll = QScrollArea(self)
|
|
294
|
+
scroll.setWidgetResizable(True)
|
|
295
|
+
scroll.setWidget(content)
|
|
296
|
+
|
|
297
|
+
buttons = QWidget(self)
|
|
298
|
+
buttons_layout = QHBoxLayout(buttons)
|
|
299
|
+
buttons_layout.setContentsMargins(0, 0, 0, 0)
|
|
300
|
+
buttons_layout.addWidget(self.clear_button)
|
|
301
|
+
buttons_layout.addStretch()
|
|
302
|
+
buttons_layout.addWidget(self.submit_button)
|
|
303
|
+
|
|
304
|
+
layout = QVBoxLayout(self)
|
|
305
|
+
layout.addWidget(scroll)
|
|
306
|
+
layout.addWidget(self.status_label)
|
|
307
|
+
layout.addWidget(buttons)
|
|
308
|
+
|
|
309
|
+
def form_data(self) -> FactsFormData:
|
|
310
|
+
"""The whole form's raw text as one submission."""
|
|
311
|
+
return FactsFormData(
|
|
312
|
+
person=self.person_form.values(),
|
|
313
|
+
spending=self.spending_form.values(),
|
|
314
|
+
state_pension=self.state_pension_form.values(),
|
|
315
|
+
wrappers=self.wrappers.values_list(),
|
|
316
|
+
db_pensions=self.db_pensions.values_list(),
|
|
317
|
+
annuity_purchases=self.annuity_purchases.values_list(),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def set_form_data(self, data: FactsFormData) -> None:
|
|
321
|
+
"""Replace the whole form's raw text (e.g. the launch example)."""
|
|
322
|
+
self.person_form.clear()
|
|
323
|
+
self.person_form.set_values(data.person)
|
|
324
|
+
self.spending_form.clear()
|
|
325
|
+
self.spending_form.set_values(data.spending)
|
|
326
|
+
self.state_pension_form.clear()
|
|
327
|
+
self.state_pension_form.set_values(data.state_pension)
|
|
328
|
+
self.wrappers.set_values_list(data.wrappers)
|
|
329
|
+
self.db_pensions.set_values_list(data.db_pensions)
|
|
330
|
+
self.annuity_purchases.set_values_list(data.annuity_purchases)
|
|
331
|
+
|
|
332
|
+
def set_entity_ids(self, ids: PlanEntityIds) -> None:
|
|
333
|
+
"""Seed every repeatable row's entity id from the saved plan.
|
|
334
|
+
|
|
335
|
+
Called after a successful save so rows the user typed fresh
|
|
336
|
+
adopt their minted ids — the next resubmission then edits the
|
|
337
|
+
same entities instead of minting again (§4.3).
|
|
338
|
+
"""
|
|
339
|
+
self.wrappers.set_entity_ids(ids.wrappers)
|
|
340
|
+
self.db_pensions.set_entity_ids(ids.db_pensions)
|
|
341
|
+
self.annuity_purchases.set_entity_ids(ids.annuity_purchases)
|
|
342
|
+
|
|
343
|
+
def submit(self) -> None:
|
|
344
|
+
"""Forward the submission and show the returned status text."""
|
|
345
|
+
self.status_label.setText(self._on_submit(self.form_data()))
|
|
346
|
+
|
|
347
|
+
def clear(self) -> None:
|
|
348
|
+
"""Empty every section, then show the clear callback's status."""
|
|
349
|
+
self.set_form_data(FactsFormData())
|
|
350
|
+
self.status_label.setText(self._on_clear())
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
__all__ = [
|
|
354
|
+
"DateEntry",
|
|
355
|
+
"FactsEntryPane",
|
|
356
|
+
"RepeatableSection",
|
|
357
|
+
"ScrollSafeComboBox",
|
|
358
|
+
"SectionForm",
|
|
359
|
+
]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""The stated-vs-assumed widgets (§4.7, roadmap 8.3).
|
|
2
|
+
|
|
3
|
+
Four read-only tables — facts, assumptions, decisions, and the
|
|
4
|
+
entity-level plan structure (issue #70) — bound to the app layer's
|
|
5
|
+
:class:`~glidepath.app.InspectorViewModel`. Double-clicking an
|
|
6
|
+
assumption row opens the in-place override prompt (multi-line for
|
|
7
|
+
table-valued rows, issue #71); the new raw text goes straight back to
|
|
8
|
+
the app layer.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from PySide6.QtWidgets import (
|
|
14
|
+
QGroupBox,
|
|
15
|
+
QHBoxLayout,
|
|
16
|
+
QInputDialog,
|
|
17
|
+
QLabel,
|
|
18
|
+
QVBoxLayout,
|
|
19
|
+
QWidget,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from glidepath.gui.tableview import fill_table, read_only_table
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
|
|
27
|
+
from glidepath.app import AssumptionRow, InspectorViewModel
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InspectorPane(QWidget):
|
|
31
|
+
"""The stated-vs-assumed tab (roadmap 8.3)."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
on_override: Callable[[str, str], str | None],
|
|
36
|
+
parent: QWidget | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Build the stacked surface and wire the override callback.
|
|
39
|
+
|
|
40
|
+
Facts and choices share the top row; every other table gets the
|
|
41
|
+
full width, so no section is squeezed into a horizontal
|
|
42
|
+
scrollbar.
|
|
43
|
+
"""
|
|
44
|
+
super().__init__(parent)
|
|
45
|
+
self._on_override = on_override
|
|
46
|
+
self._view_model: InspectorViewModel | None = None
|
|
47
|
+
|
|
48
|
+
self._facts_box = QGroupBox(self)
|
|
49
|
+
self.facts_table = read_only_table(self._facts_box)
|
|
50
|
+
facts_layout = QVBoxLayout(self._facts_box)
|
|
51
|
+
facts_layout.addWidget(self.facts_table)
|
|
52
|
+
|
|
53
|
+
self._roll_forwards_box = QGroupBox(self)
|
|
54
|
+
self.roll_forwards_table = read_only_table(self._roll_forwards_box)
|
|
55
|
+
roll_forwards_layout = QVBoxLayout(self._roll_forwards_box)
|
|
56
|
+
roll_forwards_layout.addWidget(self.roll_forwards_table)
|
|
57
|
+
|
|
58
|
+
self._assumptions_box = QGroupBox(self)
|
|
59
|
+
self.assumptions_table = read_only_table(self._assumptions_box)
|
|
60
|
+
self.assumptions_table.cellDoubleClicked.connect(self._edit_assumption)
|
|
61
|
+
assumptions_layout = QVBoxLayout(self._assumptions_box)
|
|
62
|
+
assumptions_layout.addWidget(self.assumptions_table)
|
|
63
|
+
|
|
64
|
+
self._decisions_box = QGroupBox(self)
|
|
65
|
+
self.decisions_table = read_only_table(self._decisions_box)
|
|
66
|
+
decisions_layout = QVBoxLayout(self._decisions_box)
|
|
67
|
+
decisions_layout.addWidget(self.decisions_table)
|
|
68
|
+
|
|
69
|
+
self._structure_box = QGroupBox(self)
|
|
70
|
+
self.structure_table = read_only_table(self._structure_box)
|
|
71
|
+
structure_layout = QVBoxLayout(self._structure_box)
|
|
72
|
+
structure_layout.addWidget(self.structure_table)
|
|
73
|
+
|
|
74
|
+
self.summary_label = QLabel("", self)
|
|
75
|
+
self.summary_label.setWordWrap(True)
|
|
76
|
+
self.status_label = QLabel("", self)
|
|
77
|
+
self.status_label.setWordWrap(True)
|
|
78
|
+
|
|
79
|
+
stated_row = QHBoxLayout()
|
|
80
|
+
stated_row.addWidget(self._facts_box, 4)
|
|
81
|
+
stated_row.addWidget(self._decisions_box, 3)
|
|
82
|
+
|
|
83
|
+
layout = QVBoxLayout(self)
|
|
84
|
+
layout.addLayout(stated_row, 2)
|
|
85
|
+
layout.addWidget(self._roll_forwards_box, 1)
|
|
86
|
+
layout.addWidget(self._assumptions_box, 3)
|
|
87
|
+
layout.addWidget(self._structure_box, 2)
|
|
88
|
+
layout.addWidget(self.summary_label)
|
|
89
|
+
layout.addWidget(self.status_label)
|
|
90
|
+
|
|
91
|
+
def refresh(self, view_model: InspectorViewModel) -> None:
|
|
92
|
+
"""Re-render every table from a fresh view model."""
|
|
93
|
+
self._view_model = view_model
|
|
94
|
+
self._facts_box.setTitle(view_model.facts_heading)
|
|
95
|
+
self._assumptions_box.setTitle(view_model.assumptions_heading)
|
|
96
|
+
self._decisions_box.setTitle(view_model.decisions_heading)
|
|
97
|
+
fill_table(
|
|
98
|
+
self.facts_table,
|
|
99
|
+
view_model.facts_columns,
|
|
100
|
+
[
|
|
101
|
+
(row.label, row.value, row.as_of, row.recorded)
|
|
102
|
+
for row in view_model.facts
|
|
103
|
+
],
|
|
104
|
+
)
|
|
105
|
+
fill_table(
|
|
106
|
+
self.assumptions_table,
|
|
107
|
+
view_model.assumptions_columns,
|
|
108
|
+
[
|
|
109
|
+
(
|
|
110
|
+
row.label,
|
|
111
|
+
row.value,
|
|
112
|
+
row.default_value,
|
|
113
|
+
row.status,
|
|
114
|
+
row.usage,
|
|
115
|
+
row.source,
|
|
116
|
+
row.recorded,
|
|
117
|
+
)
|
|
118
|
+
for row in view_model.assumptions
|
|
119
|
+
],
|
|
120
|
+
# Joining id to description is layout, not copy — both
|
|
121
|
+
# strings come from the app layer (§4.7).
|
|
122
|
+
tooltips=[
|
|
123
|
+
f"{row.description} ({row.key})" for row in view_model.assumptions
|
|
124
|
+
],
|
|
125
|
+
)
|
|
126
|
+
fill_table(
|
|
127
|
+
self.decisions_table,
|
|
128
|
+
view_model.decisions_columns,
|
|
129
|
+
[(row.label, row.value, row.recorded) for row in view_model.decisions],
|
|
130
|
+
)
|
|
131
|
+
self._roll_forwards_box.setTitle(view_model.roll_forwards_heading)
|
|
132
|
+
self._roll_forwards_box.setVisible(bool(view_model.roll_forwards))
|
|
133
|
+
fill_table(
|
|
134
|
+
self.roll_forwards_table,
|
|
135
|
+
view_model.roll_forwards_columns,
|
|
136
|
+
[
|
|
137
|
+
(row.label, row.stated, row.as_of, row.months, row.opening)
|
|
138
|
+
for row in view_model.roll_forwards
|
|
139
|
+
],
|
|
140
|
+
)
|
|
141
|
+
self._structure_box.setTitle(view_model.structure_heading)
|
|
142
|
+
fill_table(
|
|
143
|
+
self.structure_table,
|
|
144
|
+
view_model.structure_columns,
|
|
145
|
+
[(row.entity, row.setting, row.value) for row in view_model.structure],
|
|
146
|
+
)
|
|
147
|
+
self.summary_label.setText(view_model.summary)
|
|
148
|
+
self.summary_label.setToolTip(view_model.summary_detail)
|
|
149
|
+
|
|
150
|
+
def assumption_row(self, row_index: int) -> AssumptionRow:
|
|
151
|
+
"""The assumption row behind a table row (refresh must have run)."""
|
|
152
|
+
if self._view_model is None:
|
|
153
|
+
msg = "refresh() must run before rows can be read"
|
|
154
|
+
raise RuntimeError(msg)
|
|
155
|
+
return self._view_model.assumptions[row_index]
|
|
156
|
+
|
|
157
|
+
def _edit_assumption(self, row_index: int, _column: int) -> None:
|
|
158
|
+
"""Open the in-place override prompt for a double-clicked row."""
|
|
159
|
+
if self._view_model is None: # pragma: no cover - tables imply a model
|
|
160
|
+
return
|
|
161
|
+
row = self.assumption_row(row_index)
|
|
162
|
+
# Joining the row's label to the prompt is layout, not copy —
|
|
163
|
+
# both strings come from the app layer (§4.7).
|
|
164
|
+
if row.structured:
|
|
165
|
+
text, accepted = QInputDialog.getMultiLineText(
|
|
166
|
+
self,
|
|
167
|
+
self._view_model.override_title,
|
|
168
|
+
f"{row.label} — {self._view_model.table_override_prompt}",
|
|
169
|
+
row.edit_text,
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
text, accepted = QInputDialog.getText(
|
|
173
|
+
self,
|
|
174
|
+
self._view_model.override_title,
|
|
175
|
+
f"{row.label} — {self._view_model.override_prompt}",
|
|
176
|
+
text=row.edit_text,
|
|
177
|
+
)
|
|
178
|
+
if not accepted:
|
|
179
|
+
return
|
|
180
|
+
error = self._on_override(row.key, text)
|
|
181
|
+
self.status_label.setText(error or "")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
__all__ = [
|
|
185
|
+
"InspectorPane",
|
|
186
|
+
]
|
glidepath/gui/main.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Desktop entry point: first-run disclaimer, then the shell (§1, §4.7).
|
|
2
|
+
|
|
3
|
+
The disclaimer must be accepted before the shell opens; declining
|
|
4
|
+
exits without recording anything, so the next launch asks again.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
|
|
11
|
+
from PySide6.QtWidgets import QApplication
|
|
12
|
+
|
|
13
|
+
from glidepath.app import (
|
|
14
|
+
build_shell_view_model,
|
|
15
|
+
default_state_path,
|
|
16
|
+
load_state,
|
|
17
|
+
record_disclaimer_acknowledged,
|
|
18
|
+
should_show_disclaimer,
|
|
19
|
+
)
|
|
20
|
+
from glidepath.gui import widgets
|
|
21
|
+
from glidepath.gui.style import apply_theme
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run(argv: list[str] | None = None) -> int:
|
|
25
|
+
"""Run the desktop shell; returns the process exit code."""
|
|
26
|
+
app = QApplication.instance() or QApplication(sys.argv if argv is None else argv)
|
|
27
|
+
if isinstance(app, QApplication):
|
|
28
|
+
apply_theme(app)
|
|
29
|
+
view_model = build_shell_view_model()
|
|
30
|
+
state_path = default_state_path()
|
|
31
|
+
first_run = load_state(state_path)
|
|
32
|
+
if should_show_disclaimer(first_run):
|
|
33
|
+
if not widgets.prompt_disclaimer(view_model.disclaimer):
|
|
34
|
+
return 0
|
|
35
|
+
# The acknowledgement file only skips future prompts; an unwritable
|
|
36
|
+
# config directory must not kill the session the user just accepted
|
|
37
|
+
# (worst case the next launch asks again).
|
|
38
|
+
with contextlib.suppress(OSError):
|
|
39
|
+
record_disclaimer_acknowledged(state_path, datetime.now(tz=UTC).date())
|
|
40
|
+
window = widgets.MainWindow(view_model, settings_path=state_path)
|
|
41
|
+
# Reopen the last saved or opened plan; if it fails to load, the
|
|
42
|
+
# launch example stays on screen and the status bar explains.
|
|
43
|
+
if first_run.last_plan_path is not None:
|
|
44
|
+
window.open_plan(first_run.last_plan_path)
|
|
45
|
+
window.show()
|
|
46
|
+
return app.exec()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main() -> None:
|
|
50
|
+
"""Console-script entry point."""
|
|
51
|
+
raise SystemExit(run())
|