composeui 0.1.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.
- composeui/__init__.py +11 -0
- composeui/core/__init__.py +0 -0
- composeui/core/application.py +368 -0
- composeui/core/chartcolors.py +23 -0
- composeui/core/chartimage.py +336 -0
- composeui/core/dataframesource.py +307 -0
- composeui/core/jobs.py +426 -0
- composeui/core/model.py +66 -0
- composeui/core/registry.py +66 -0
- composeui/core/reload.py +476 -0
- composeui/core/renderer.py +193 -0
- composeui/core/signal.py +358 -0
- composeui/core/sqlitemodel.py +1183 -0
- composeui/core/study.py +121 -0
- composeui/core/translation.py +221 -0
- composeui/core/translationcheck.py +254 -0
- composeui/core/view.py +5508 -0
- composeui/images/composeui.svg +15 -0
- composeui/locales/fr.json +73 -0
- composeui/mypy.py +46 -0
- composeui/py.typed +0 -0
- composeui/qtcore/__init__.py +0 -0
- composeui/qtcore/maprenderer.py +115 -0
- composeui/qtcore/qtapplication.py +222 -0
- composeui/qtcore/qtrenderer.py +2862 -0
- composeui/qtcore/widgets/__init__.py +0 -0
- composeui/qtcore/widgets/map.qml +123 -0
- composeui/qtcore/widgets/qchart.py +393 -0
- composeui/qtcore/widgets/qdatefield.py +101 -0
- composeui/qtcore/widgets/qexpander.py +60 -0
- composeui/qtcore/widgets/qfilepathfield.py +34 -0
- composeui/qtcore/widgets/qfiltertable.py +394 -0
- composeui/qtcore/widgets/qformdialog.py +64 -0
- composeui/qtcore/widgets/qformlabel.py +53 -0
- composeui/qtcore/widgets/qmap.py +68 -0
- composeui/qtcore/widgets/qmarkicon.py +37 -0
- composeui/qtcore/widgets/qmetric.py +44 -0
- composeui/qtcore/widgets/qplaintext.py +29 -0
- composeui/qtcore/widgets/qradiogroup.py +57 -0
- composeui/qtcore/widgets/qsidebar.py +116 -0
- composeui/qtcore/widgets/qsourcetable.py +216 -0
- composeui/qtcore/widgets/qsplitter.py +69 -0
- composeui/qtcore/widgets/qtexttable.py +703 -0
- composeui/qtcore/widgets/qvaluecombobox.py +41 -0
- composeui/qtcore/widgets/qvalueslider.py +64 -0
- composeui/qtcore/widgets/qwindowrelease.py +32 -0
- composeui/textualcore/__init__.py +0 -0
- composeui/textualcore/textualapplication.py +121 -0
- composeui/textualcore/textualrenderer.py +2893 -0
- composeui/textualcore/widgets/__init__.py +0 -0
- composeui/textualcore/widgets/composebutton.py +17 -0
- composeui/textualcore/widgets/composechart.py +212 -0
- composeui/textualcore/widgets/composecheckbox.py +35 -0
- composeui/textualcore/widgets/composecollapsible.py +41 -0
- composeui/textualcore/widgets/composecombobox.py +54 -0
- composeui/textualcore/widgets/composedateinput.py +325 -0
- composeui/textualcore/widgets/composedialog.py +90 -0
- composeui/textualcore/widgets/composefilepathinput.py +85 -0
- composeui/textualcore/widgets/composefiltertable.py +367 -0
- composeui/textualcore/widgets/composeformdialog.py +86 -0
- composeui/textualcore/widgets/composeinput.py +50 -0
- composeui/textualcore/widgets/composelist.py +20 -0
- composeui/textualcore/widgets/composemarkdown.py +17 -0
- composeui/textualcore/widgets/composemetric.py +60 -0
- composeui/textualcore/widgets/composeradioset.py +54 -0
- composeui/textualcore/widgets/composesidebar.py +111 -0
- composeui/textualcore/widgets/composeslider.py +144 -0
- composeui/textualcore/widgets/composesourcetable.py +288 -0
- composeui/textualcore/widgets/composesplitter.py +211 -0
- composeui/textualcore/widgets/composetabbedcontent.py +35 -0
- composeui/textualcore/widgets/composetable.py +476 -0
- composeui/textualcore/widgets/composetoolbar.py +62 -0
- composeui/textualcore/widgets/composetree.py +139 -0
- composeui/webcore/__init__.py +0 -0
- composeui/webcore/headers.py +57 -0
- composeui/webcore/icons.py +58 -0
- composeui/webcore/limits.py +55 -0
- composeui/webcore/origin.py +63 -0
- composeui/webcore/page.py +146 -0
- composeui/webcore/routes.py +303 -0
- composeui/webcore/session.py +436 -0
- composeui/webcore/static/app.css +2 -0
- composeui/webcore/static/cally.js +1312 -0
- composeui/webcore/static/daisyui.LICENSE +21 -0
- composeui/webcore/static/deckgl.LICENSE +19 -0
- composeui/webcore/static/deckgl.min.js +6423 -0
- composeui/webcore/static/htmx.min.js +1 -0
- composeui/webcore/static/page.js +1175 -0
- composeui/webcore/static/uPlot.LICENSE +21 -0
- composeui/webcore/static/uPlot.iife.min.js +2 -0
- composeui/webcore/webapplication.py +495 -0
- composeui/webcore/webrenderer.py +5113 -0
- composeui-0.1.0.dist-info/METADATA +222 -0
- composeui-0.1.0.dist-info/RECORD +97 -0
- composeui-0.1.0.dist-info/WHEEL +5 -0
- composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
- composeui-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
2
|
+
<!-- the default icon of an app (`MainView.app_icon`), and the documentation's favicon:
|
|
3
|
+
ComposeUI's icon without its fan-out lines and view square, which blur when small -->
|
|
4
|
+
<defs>
|
|
5
|
+
<linearGradient id="composeui-bg" x1="0" y1="0" x2="1" y2="1">
|
|
6
|
+
<stop offset="0" stop-color="#3446a8"/>
|
|
7
|
+
<stop offset="1" stop-color="#1d1a4f"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
</defs>
|
|
10
|
+
<rect width="64" height="64" rx="14" fill="url(#composeui-bg)"/>
|
|
11
|
+
<path d="M33.5 19.9 A14 14 0 1 0 33.5 44.1" fill="none" stroke="#fff" stroke-width="8" stroke-linecap="round"/>
|
|
12
|
+
<circle cx="44.2" cy="14.3" r="4.6" fill="#2fd4c4"/>
|
|
13
|
+
<circle cx="51.5" cy="32.0" r="4.6" fill="#ffc857"/>
|
|
14
|
+
<circle cx="44.2" cy="49.7" r="4.6" fill="#ff8fc7"/>
|
|
15
|
+
</svg>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"File": "Fichier",
|
|
3
|
+
"New": "Nouveau",
|
|
4
|
+
"Open": "Ouvrir",
|
|
5
|
+
"Save": "Enregistrer",
|
|
6
|
+
"Save As": "Enregistrer sous",
|
|
7
|
+
"Exit": "Quitter",
|
|
8
|
+
"OK": "OK",
|
|
9
|
+
"Cancel": "Annuler",
|
|
10
|
+
"Help": "Aide",
|
|
11
|
+
"Can't open {name}: {error}.": "Impossible d'ouvrir {name} : {error}.",
|
|
12
|
+
"Language": "Langue",
|
|
13
|
+
"Save Image": "Enregistrer l'image",
|
|
14
|
+
"{header}: \"{text}\" isn't a valid value": "{header} : « {text} » n'est pas une valeur valide",
|
|
15
|
+
"Search": "Rechercher",
|
|
16
|
+
"+ Filter": "+ Filtre",
|
|
17
|
+
"Column": "Colonne",
|
|
18
|
+
"Value": "Valeur",
|
|
19
|
+
"Add": "Ajouter",
|
|
20
|
+
"Clear": "Effacer",
|
|
21
|
+
"{shown} of {total}": "{shown} sur {total}",
|
|
22
|
+
"No rows match the filters": "Aucune ligne ne correspond aux filtres",
|
|
23
|
+
"Remove the filter on {header}": "Retirer le filtre sur {header}",
|
|
24
|
+
"Type a value to filter by": "Saisissez une valeur pour filtrer",
|
|
25
|
+
"\"{text}\" isn't a number": "« {text} » n'est pas un nombre",
|
|
26
|
+
"\"{text}\" isn't a date, as 2024-01-31": "« {text} » n'est pas une date, comme 2024-01-31",
|
|
27
|
+
"\"{text}\" isn't true or false": "« {text} » n'est ni true ni false",
|
|
28
|
+
"The range's start is after its end": "Le début de l'intervalle est après sa fin",
|
|
29
|
+
">=20, 18..24": ">=20, 18..24",
|
|
30
|
+
">=2024-01-31": ">=2024-01-31",
|
|
31
|
+
"true or false": "true ou false",
|
|
32
|
+
"\"{text}\" isn't a valid regular expression": "« {text} » n'est pas une expression régulière valide",
|
|
33
|
+
"Edit the filter on {header}": "Modifier le filtre sur {header}",
|
|
34
|
+
"{header}: {filter}": "{header} : {filter}",
|
|
35
|
+
"contains, =exact, start*, !not": "contient, =exact, début*, !sauf",
|
|
36
|
+
"a regular expression, as Room [1-3]": "une expression régulière, comme Pièce [1-3]",
|
|
37
|
+
"{header} matches {filter}": "{header} correspond à {filter}",
|
|
38
|
+
"Regular expression": "Expression régulière",
|
|
39
|
+
"Sidebar": "Panneau latéral",
|
|
40
|
+
"MM/dd/yyyy": "dd/MM/yyyy",
|
|
41
|
+
"January": "janvier",
|
|
42
|
+
"February": "février",
|
|
43
|
+
"March": "mars",
|
|
44
|
+
"April": "avril",
|
|
45
|
+
"May": "mai",
|
|
46
|
+
"June": "juin",
|
|
47
|
+
"July": "juillet",
|
|
48
|
+
"August": "août",
|
|
49
|
+
"September": "septembre",
|
|
50
|
+
"October": "octobre",
|
|
51
|
+
"November": "novembre",
|
|
52
|
+
"December": "décembre",
|
|
53
|
+
"Mo": "lu",
|
|
54
|
+
"Tu": "ma",
|
|
55
|
+
"We": "me",
|
|
56
|
+
"Th": "je",
|
|
57
|
+
"Fr": "ve",
|
|
58
|
+
"Sa": "sa",
|
|
59
|
+
"Su": "di",
|
|
60
|
+
"Pick a date": "Choisir une date",
|
|
61
|
+
"Previous month": "Mois précédent",
|
|
62
|
+
"Next month": "Mois suivant",
|
|
63
|
+
"Resize the panes": "Redimensionner les volets",
|
|
64
|
+
"Done": "Terminé",
|
|
65
|
+
"Warning": "Avertissement",
|
|
66
|
+
"Error": "Erreur",
|
|
67
|
+
"Choose a file": "Choisir un fichier",
|
|
68
|
+
"Reload": "Recharger",
|
|
69
|
+
"No rows": "Aucune ligne",
|
|
70
|
+
"Export CSV": "Exporter en CSV",
|
|
71
|
+
"Exported {count} rows to {name}.": "{count} lignes exportées dans {name}.",
|
|
72
|
+
"Can't export to {name}: {error}.": "Impossible d'exporter dans {name} : {error}."
|
|
73
|
+
}
|
composeui/mypy.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Mypy integration for CSV row classes and field factories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from mypy.nodes import AssignmentStmt, CallExpr, MemberExpr, RefExpr, TypeInfo
|
|
8
|
+
from mypy.plugin import Plugin
|
|
9
|
+
from mypy.plugins.attrs import attr_attrib_makers, attr_class_maker_callback, attr_define_makers
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
from mypy.plugin import ClassDefContext
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_DEFINE_ROW = "composeui.core.sqlitemodel.define_csv_row"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _define_csv_row(ctx: ClassDefContext) -> bool:
|
|
21
|
+
# Mypy's attrs plugin expects converter references to have a resolved node.
|
|
22
|
+
# Built-in method descriptors (e.g. str.strip) need their owning type's symbol.
|
|
23
|
+
for statement in ctx.cls.defs.body:
|
|
24
|
+
if not isinstance(statement, AssignmentStmt) or not isinstance(statement.rvalue, CallExpr):
|
|
25
|
+
continue
|
|
26
|
+
for name, expression in zip(statement.rvalue.arg_names, statement.rvalue.args):
|
|
27
|
+
if name != "converter" or not isinstance(expression, MemberExpr):
|
|
28
|
+
continue
|
|
29
|
+
owner = expression.expr
|
|
30
|
+
if isinstance(owner, RefExpr) and isinstance(owner.node, TypeInfo):
|
|
31
|
+
symbol = owner.node.get(expression.name)
|
|
32
|
+
if symbol is not None:
|
|
33
|
+
expression.node = symbol.node
|
|
34
|
+
return attr_class_maker_callback(ctx, auto_attribs_default=None, slots_default=True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _CSVPlugin(Plugin):
|
|
38
|
+
def get_class_decorator_hook_2(self, fullname: str) -> Callable[[ClassDefContext], bool] | None:
|
|
39
|
+
return _define_csv_row if fullname == _DEFINE_ROW else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def plugin(_version: str) -> type[Plugin]:
|
|
43
|
+
"""Register the CSV field factory and row decorator with mypy's attrs plugin."""
|
|
44
|
+
attr_attrib_makers.add("composeui.core.sqlitemodel.csv_field")
|
|
45
|
+
attr_define_makers.add(_DEFINE_ROW)
|
|
46
|
+
return _CSVPlugin
|
composeui/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""The map renderer, loaded only when an application uses a map."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from typing_extensions import Protocol
|
|
8
|
+
|
|
9
|
+
from composeui.core.renderer import ViewLike
|
|
10
|
+
from composeui.core.view import MapPoint, MapView
|
|
11
|
+
from composeui.qtcore.qtrenderer import QtWidgetRenderer
|
|
12
|
+
from composeui.qtcore.widgets.qmap import QMap
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from composeui.core.signal import Signal, SignalNoArgs
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MapViewLike(ViewLike, Protocol):
|
|
19
|
+
title: str
|
|
20
|
+
center: MapPoint | None
|
|
21
|
+
zoom: float | None
|
|
22
|
+
points: list[MapPoint]
|
|
23
|
+
height: int
|
|
24
|
+
tiles: bool
|
|
25
|
+
user_agent: str
|
|
26
|
+
clicked: Signal[float, float]
|
|
27
|
+
viewport_changed: SignalNoArgs
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class QtMapRenderer(QtWidgetRenderer[MapViewLike, QMap]):
|
|
31
|
+
def __init__(self, view: MapViewLike) -> None:
|
|
32
|
+
super().__init__(view)
|
|
33
|
+
self._refresh_dispatcher.update(
|
|
34
|
+
{
|
|
35
|
+
"title": self._refresh_title,
|
|
36
|
+
"center": self._refresh_center,
|
|
37
|
+
"zoom": self._refresh_zoom,
|
|
38
|
+
"points": self._refresh_points,
|
|
39
|
+
"height": self._refresh_height,
|
|
40
|
+
"tiles": self._refresh_tiles,
|
|
41
|
+
"user_agent": self._refresh_user_agent,
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def render(self) -> None:
|
|
46
|
+
if self._rendered_object is None:
|
|
47
|
+
widget = self._rendered_object = QMap(self._view.user_agent, tiles=self._view.tiles)
|
|
48
|
+
widget.viewport_moved.connect(self._viewport_moved)
|
|
49
|
+
widget.point_clicked.connect(self._view.clicked.emit)
|
|
50
|
+
widget.map.resized.connect(self._resize)
|
|
51
|
+
self.refresh()
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _refresh_title(_view: MapViewLike, widget: QMap, value: str) -> None:
|
|
55
|
+
widget.title.setText(value)
|
|
56
|
+
widget.title.setVisible(bool(value))
|
|
57
|
+
widget.map.setAccessibleName(value)
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _refresh_center(view: MapViewLike, widget: QMap, value: MapPoint | None) -> None:
|
|
61
|
+
QtMapRenderer._fit(widget, view.points, value, view.zoom)
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _refresh_zoom(view: MapViewLike, widget: QMap, value: float | None) -> None:
|
|
65
|
+
QtMapRenderer._fit(widget, view.points, view.center, value)
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _refresh_points(view: MapViewLike, widget: QMap, value: list[MapPoint]) -> None:
|
|
69
|
+
widget.root.setProperty("points", [[p.latitude, p.longitude] for p in value])
|
|
70
|
+
QtMapRenderer._fit(widget, value, view.center, view.zoom)
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _fit(
|
|
74
|
+
widget: QMap, points: list[MapPoint], center: MapPoint | None, zoom: float | None
|
|
75
|
+
) -> None:
|
|
76
|
+
position, span_x, span_y = MapView.point_bounds(points, center)
|
|
77
|
+
widget.root.setProperty("latitude", position.latitude)
|
|
78
|
+
widget.root.setProperty("longitude", position.longitude)
|
|
79
|
+
widget.root.setProperty(
|
|
80
|
+
"zoom",
|
|
81
|
+
zoom
|
|
82
|
+
if zoom is not None
|
|
83
|
+
else MapView.fitted_zoom(span_x, span_y, widget.map.width(), widget.map.height()),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def _resize(self) -> None:
|
|
87
|
+
widget = self._rendered_object
|
|
88
|
+
assert widget is not None
|
|
89
|
+
view = self._view
|
|
90
|
+
self._fit(widget, view.points, view.center, view.zoom)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _refresh_height(_view: MapViewLike, widget: QMap, value: int) -> None:
|
|
94
|
+
widget.map.setFixedHeight(value)
|
|
95
|
+
|
|
96
|
+
def _configure(self, view: MapViewLike, widget: QMap, user_agent: str, *, tiles: bool) -> None:
|
|
97
|
+
widget.configure(user_agent, tiles=tiles)
|
|
98
|
+
self._refresh_center(view, widget, view.center)
|
|
99
|
+
self._refresh_zoom(view, widget, view.zoom)
|
|
100
|
+
self._refresh_points(view, widget, view.points)
|
|
101
|
+
|
|
102
|
+
def _refresh_tiles(self, view: MapViewLike, widget: QMap, value: bool) -> None:
|
|
103
|
+
self._configure(view, widget, view.user_agent, tiles=value)
|
|
104
|
+
|
|
105
|
+
def _refresh_user_agent(self, view: MapViewLike, widget: QMap, value: str) -> None:
|
|
106
|
+
self._configure(view, widget, value, tiles=view.tiles)
|
|
107
|
+
|
|
108
|
+
def _viewport_moved(self, latitude: float, longitude: float, zoom: float) -> None:
|
|
109
|
+
with self.stop_refresh():
|
|
110
|
+
self._view.center = MapPoint(
|
|
111
|
+
latitude=latitude,
|
|
112
|
+
longitude=longitude,
|
|
113
|
+
)
|
|
114
|
+
self._view.zoom = zoom
|
|
115
|
+
self._view.viewport_changed.emit()
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""The application classes of the Qt backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
from typing import TYPE_CHECKING, NamedTuple, TypeVar
|
|
9
|
+
|
|
10
|
+
from qtpy.QtCore import QThread, QTimer
|
|
11
|
+
from qtpy.QtWidgets import QApplication, QMainWindow, QWidget
|
|
12
|
+
|
|
13
|
+
from composeui.core.application import Application, ModelApplication
|
|
14
|
+
from composeui.core.reload import (
|
|
15
|
+
acknowledge_changes,
|
|
16
|
+
can_reload_in_place,
|
|
17
|
+
is_reloaded,
|
|
18
|
+
keep_rerun,
|
|
19
|
+
load_reloaded_state,
|
|
20
|
+
rerun_program,
|
|
21
|
+
run_reloading,
|
|
22
|
+
save_reloaded_state,
|
|
23
|
+
take_changes,
|
|
24
|
+
)
|
|
25
|
+
from composeui.core.view import RendererType
|
|
26
|
+
from composeui.qtcore.qtrenderer import QtMainRenderer
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
import argparse
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from composeui.core.model import Model
|
|
34
|
+
from composeui.core.view import MainView, View
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
LOGGER = logging.getLogger("COMPOSEUI")
|
|
38
|
+
|
|
39
|
+
#: milliseconds between two looks, in a reloaded app, at whether files changed
|
|
40
|
+
_CHANGES_CHECK_INTERVAL = 200
|
|
41
|
+
|
|
42
|
+
MainViewT = TypeVar("MainViewT", bound="MainView")
|
|
43
|
+
ModelT = TypeVar("ModelT", bound="Model")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Run(NamedTuple):
|
|
47
|
+
"""An app ready to show its main view, its command line parsed."""
|
|
48
|
+
|
|
49
|
+
args: argparse.Namespace
|
|
50
|
+
main_view: MainView
|
|
51
|
+
model: Model | None
|
|
52
|
+
render: Callable[[RendererType], None]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _add_reload_argument(parser: argparse.ArgumentParser) -> None:
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--reload",
|
|
58
|
+
help="Run the app again when a Python file under the current directory changes.",
|
|
59
|
+
action="store_true",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _exec(run: _Run) -> int:
|
|
64
|
+
"""Show the main view until its window is closed, or run it again on every change."""
|
|
65
|
+
if run.args.reload and not is_reloaded():
|
|
66
|
+
# never shown here: closed first, as the child processes build and open their own
|
|
67
|
+
if run.model is not None:
|
|
68
|
+
run.model.close()
|
|
69
|
+
# the app runs in a child process, which a new one replaces when it can't run the
|
|
70
|
+
# changed code itself
|
|
71
|
+
return run_reloading()
|
|
72
|
+
app = QApplication(sys.argv)
|
|
73
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL) # kill the program when Ctr+c
|
|
74
|
+
if not is_reloaded():
|
|
75
|
+
run.render(RendererType.QT)
|
|
76
|
+
try:
|
|
77
|
+
return app.exec()
|
|
78
|
+
finally:
|
|
79
|
+
if run.model is not None:
|
|
80
|
+
run.model.close()
|
|
81
|
+
reloads = _Reloads(run)
|
|
82
|
+
try:
|
|
83
|
+
return app.exec()
|
|
84
|
+
finally:
|
|
85
|
+
reloads.close()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _Reloads:
|
|
89
|
+
"""The window of a reloaded app, which shows the app the changed code builds."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, run: _Run) -> None:
|
|
92
|
+
self._run = run
|
|
93
|
+
#: the files changed, taken, whose code the app runs once it can
|
|
94
|
+
self._changed: list[Path] | None = None
|
|
95
|
+
load_reloaded_state(run.main_view, run.model)
|
|
96
|
+
run.render(RendererType.QT)
|
|
97
|
+
self._timer = QTimer()
|
|
98
|
+
self._timer.timeout.connect(self._follow_changes)
|
|
99
|
+
self._timer.start(_CHANGES_CHECK_INTERVAL)
|
|
100
|
+
|
|
101
|
+
def close(self) -> None:
|
|
102
|
+
"""Close the model of the app shown last."""
|
|
103
|
+
if self._run.model is not None:
|
|
104
|
+
self._run.model.close()
|
|
105
|
+
|
|
106
|
+
def _follow_changes(self) -> None:
|
|
107
|
+
if self._changed is None:
|
|
108
|
+
self._changed = take_changes()
|
|
109
|
+
# The timer also fires in a nested event loop, e.g. a file dialog's that a callback
|
|
110
|
+
# opened: the callback goes on with the app once it returns. Not a check of the
|
|
111
|
+
# modal dialog, which its loop outlives for a last event once it's closed.
|
|
112
|
+
if self._changed is None or QThread.currentThread().loopLevel() > 1:
|
|
113
|
+
return
|
|
114
|
+
changed, self._changed = self._changed, None
|
|
115
|
+
save_reloaded_state(self._run.main_view, self._run.model)
|
|
116
|
+
if not can_reload_in_place(changed):
|
|
117
|
+
# the supervisor runs the program in a new process, which opens the state saved
|
|
118
|
+
self._timer.stop()
|
|
119
|
+
QApplication.quit()
|
|
120
|
+
return
|
|
121
|
+
try:
|
|
122
|
+
run = rerun_program()
|
|
123
|
+
except Exception:
|
|
124
|
+
LOGGER.exception("The changed code fails: the window keeps the previous code")
|
|
125
|
+
acknowledge_changes()
|
|
126
|
+
return
|
|
127
|
+
try:
|
|
128
|
+
self._show(run)
|
|
129
|
+
except Exception:
|
|
130
|
+
# the window may be half replaced: a new process shows the state saved
|
|
131
|
+
LOGGER.exception("The changed app can't show: it runs again in a new process")
|
|
132
|
+
if run.model is not None and run.model is not self._run.model:
|
|
133
|
+
run.model.close()
|
|
134
|
+
self._timer.stop()
|
|
135
|
+
QApplication.quit()
|
|
136
|
+
return
|
|
137
|
+
acknowledge_changes()
|
|
138
|
+
|
|
139
|
+
def _show(self, run: _Run) -> None:
|
|
140
|
+
"""Show `run`'s main view in the window, in place of the previous one."""
|
|
141
|
+
previous = self._run
|
|
142
|
+
renderer = previous.main_view.renderer
|
|
143
|
+
window = renderer.qt_object if isinstance(renderer, QtMainRenderer) else None
|
|
144
|
+
assert window is not None
|
|
145
|
+
load_reloaded_state(run.main_view, run.model)
|
|
146
|
+
previous.main_view.jobs.drop()
|
|
147
|
+
_forget_widgets(previous.main_view, window)
|
|
148
|
+
run.main_view.renderer = QtMainRenderer(run.main_view, window)
|
|
149
|
+
run.render(RendererType.QT)
|
|
150
|
+
self._run = run
|
|
151
|
+
if previous.model is not None and previous.model is not run.model:
|
|
152
|
+
previous.model.close()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _forget_widgets(view: View, window: QMainWindow) -> None:
|
|
156
|
+
"""Detach `view` and its children from their widgets, which the window drops.
|
|
157
|
+
|
|
158
|
+
So a late change of a view, e.g. a status bar clearing its message, reaches no widget;
|
|
159
|
+
the windows of its own, e.g. a dialog's, close.
|
|
160
|
+
"""
|
|
161
|
+
for child in view.children_views:
|
|
162
|
+
_forget_widgets(child, window)
|
|
163
|
+
widget = getattr(view.renderer, "qt_object", None)
|
|
164
|
+
if isinstance(widget, QWidget) and widget.isWindow() and widget is not window:
|
|
165
|
+
widget.close()
|
|
166
|
+
widget.deleteLater()
|
|
167
|
+
# not through the view's `on_setattr`: it would show the change on the renderer
|
|
168
|
+
object.__setattr__(view, "renderer", None)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class QtApplication(Application[MainViewT]):
|
|
172
|
+
"""The Qt app: it shows the main view in a window."""
|
|
173
|
+
|
|
174
|
+
def __init__(self, *, build_main_view: Callable[[], MainViewT]) -> None:
|
|
175
|
+
super().__init__(build_main_view=build_main_view)
|
|
176
|
+
_add_reload_argument(self._parser)
|
|
177
|
+
|
|
178
|
+
def run(self) -> None:
|
|
179
|
+
super().run()
|
|
180
|
+
assert self._args is not None
|
|
181
|
+
run = _Run(self._args, self.main_view, None, self.render)
|
|
182
|
+
keep_rerun(run)
|
|
183
|
+
sys.exit(_exec(run))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class QtModelApplication(ModelApplication[MainViewT, ModelT]):
|
|
187
|
+
"""The Qt app with a model, which it closes once the window is closed."""
|
|
188
|
+
|
|
189
|
+
def __init__(
|
|
190
|
+
self,
|
|
191
|
+
*,
|
|
192
|
+
build_main_view: Callable[[], MainViewT],
|
|
193
|
+
build_model: Callable[[], ModelT],
|
|
194
|
+
setup_model: Callable[[MainViewT, ModelT], None] | None = None,
|
|
195
|
+
) -> None:
|
|
196
|
+
try:
|
|
197
|
+
super().__init__(
|
|
198
|
+
build_main_view=build_main_view, build_model=build_model, setup_model=setup_model
|
|
199
|
+
)
|
|
200
|
+
except BaseException:
|
|
201
|
+
# built first: e.g. a reload of a program whose `setup_model` now fails
|
|
202
|
+
model: Model | None = getattr(self, "_model", None)
|
|
203
|
+
if model is not None:
|
|
204
|
+
model.close()
|
|
205
|
+
raise
|
|
206
|
+
_add_reload_argument(self._parser)
|
|
207
|
+
|
|
208
|
+
def run(self) -> None:
|
|
209
|
+
try:
|
|
210
|
+
super().run()
|
|
211
|
+
except BaseException:
|
|
212
|
+
self.model.close()
|
|
213
|
+
raise
|
|
214
|
+
assert self._args is not None
|
|
215
|
+
run = _Run(self._args, self.main_view, self.model, self.render)
|
|
216
|
+
keep_rerun(run)
|
|
217
|
+
sys.exit(_exec(run))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
#: the backend's applications, for `composeui.core.application.run`
|
|
221
|
+
APPLICATION = QtApplication
|
|
222
|
+
MODEL_APPLICATION = QtModelApplication
|