botflow-gui 0.0.1__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.
Files changed (52) hide show
  1. botflow/__init__.py +53 -0
  2. botflow/__pycache__/__init__.cpython-312.pyc +0 -0
  3. botflow/__pycache__/exceptions.cpython-312.pyc +0 -0
  4. botflow/__pycache__/i18n.cpython-312.pyc +0 -0
  5. botflow/__pycache__/logger.cpython-312.pyc +0 -0
  6. botflow/__pycache__/manager.cpython-312.pyc +0 -0
  7. botflow/__pycache__/pages.cpython-312.pyc +0 -0
  8. botflow/__pycache__/qss.cpython-312.pyc +0 -0
  9. botflow/__pycache__/resolver.cpython-312.pyc +0 -0
  10. botflow/__pycache__/runtime.cpython-312.pyc +0 -0
  11. botflow/__pycache__/threads.cpython-312.pyc +0 -0
  12. botflow/__pycache__/types.cpython-312.pyc +0 -0
  13. botflow/__pycache__/widgets.cpython-312.pyc +0 -0
  14. botflow/__pycache__/workers.cpython-312.pyc +0 -0
  15. botflow/__pyinstaller/__init__.py +0 -0
  16. botflow/__pyinstaller/__pycache__/hook-botflow.cpython-312.pyc +0 -0
  17. botflow/__pyinstaller/hook-botflow.py +10 -0
  18. botflow/exceptions.py +4 -0
  19. botflow/i18n.py +57 -0
  20. botflow/logger.py +45 -0
  21. botflow/manager.py +318 -0
  22. botflow/pages.py +117 -0
  23. botflow/qss.py +21 -0
  24. botflow/resolver.py +94 -0
  25. botflow/resources/assets/loading.gif +0 -0
  26. botflow/resources/locales/en_US/common.json +8 -0
  27. botflow/resources/locales/en_US/dialogs.json +15 -0
  28. botflow/resources/locales/en_US/initial_page.json +3 -0
  29. botflow/resources/locales/en_US/loading.json +3 -0
  30. botflow/resources/locales/en_US/messages.json +5 -0
  31. botflow/resources/locales/pt_BR/common.json +8 -0
  32. botflow/resources/locales/pt_BR/dialogs.json +15 -0
  33. botflow/resources/locales/pt_BR/initial_page.json +3 -0
  34. botflow/resources/locales/pt_BR/loading.json +3 -0
  35. botflow/resources/locales/pt_BR/messages.json +5 -0
  36. botflow/resources/styles/file_widget.qss +62 -0
  37. botflow/resources/styles/flow_choice_widget.qss +0 -0
  38. botflow/resources/styles/flow_manager.qss +51 -0
  39. botflow/resources/styles/form_widget.qss +40 -0
  40. botflow/resources/styles/initial_page.qss +30 -0
  41. botflow/resources/styles/loading_page.qss +37 -0
  42. botflow/resources/styles/text_widget.qss +33 -0
  43. botflow/runtime.py +42 -0
  44. botflow/types.py +87 -0
  45. botflow/widgets.py +244 -0
  46. botflow/workers.py +130 -0
  47. botflow_gui-0.0.1.dist-info/METADATA +100 -0
  48. botflow_gui-0.0.1.dist-info/RECORD +52 -0
  49. botflow_gui-0.0.1.dist-info/WHEEL +5 -0
  50. botflow_gui-0.0.1.dist-info/entry_points.txt +2 -0
  51. botflow_gui-0.0.1.dist-info/licenses/LICENSE +21 -0
  52. botflow_gui-0.0.1.dist-info/top_level.txt +1 -0
botflow/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ from botflow.manager import FlowManager
2
+ from botflow.types import FlowSpec
3
+ from botflow.widgets import (
4
+ FileStepSpec,
5
+ FileWidget,
6
+ FormInput,
7
+ FormStepSpec,
8
+ FormWidget,
9
+ TextStepSpec,
10
+ TextWidget,
11
+ )
12
+
13
+ __all__ = [
14
+ 'FlowManager',
15
+ 'FileStepSpec',
16
+ 'FileWidget',
17
+ 'FormInput',
18
+ 'FormStepSpec',
19
+ 'FormWidget',
20
+ 'TextStepSpec',
21
+ 'TextWidget',
22
+ 'FlowSpec',
23
+ ]
24
+
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ from PySide6.QtWidgets import QApplication
29
+
30
+
31
+ def get_hook_dirs():
32
+ return [str(Path(__file__).resolve().with_name('__pyinstaller'))]
33
+
34
+
35
+ def run_application():
36
+ app = QApplication.instance()
37
+ if not app:
38
+ app = QApplication(sys.argv)
39
+ return app
40
+
41
+
42
+ def run_flow_manager(
43
+ flow_manager: FlowManager,
44
+ *,
45
+ width: int = 720,
46
+ height: int = 300,
47
+ window_title: str = 'Flow Manager',
48
+ ):
49
+ app = run_application()
50
+ flow_manager.resize(width, height)
51
+ flow_manager.setWindowTitle(window_title)
52
+ flow_manager.show()
53
+ sys.exit(app.exec())
Binary file
Binary file
File without changes
@@ -0,0 +1,10 @@
1
+ from pathlib import Path
2
+
3
+ from PyInstaller.utils.hooks import get_module_file_attribute
4
+
5
+ module_path = get_module_file_attribute('botflow')
6
+ if module_path:
7
+ botflow_dir = Path(module_path).parent
8
+ datas = [(str(botflow_dir / 'resources'), 'lib_resources')]
9
+ else:
10
+ datas = []
botflow/exceptions.py ADDED
@@ -0,0 +1,4 @@
1
+ class PipelineExceptedError(Exception):
2
+ def __init__(self, message: str, popup_message: str | None = None):
3
+ super().__init__(message)
4
+ self.popup_message = popup_message or message
botflow/i18n.py ADDED
@@ -0,0 +1,57 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from botflow.resolver import find_all_locales_dirs
6
+
7
+
8
+ class I18n:
9
+ def __init__(self, catalog: dict[str, Any], lang: str, fallback_lang: str = 'en_US'):
10
+ self.catalog = catalog
11
+ self.lang = lang
12
+ self.fallback_lang = fallback_lang
13
+
14
+ def t(self, key: str, **params: Any) -> str:
15
+ loc = self.lang or self.fallback_lang
16
+
17
+ bucket = self.catalog.get(loc, {})
18
+ v = bucket.get(key)
19
+
20
+ if v is None and self.fallback_lang and self.fallback_lang != loc:
21
+ v = self.catalog.get(self.fallback_lang, {}).get(key)
22
+
23
+ if v is None:
24
+ raise KeyError(f'Missing translation key: {key} (lang={self.lang})')
25
+
26
+ if not isinstance(v, str):
27
+ return str(v)
28
+
29
+ return v.format(**params) if params else v
30
+
31
+ def set_lang(self, lang: str) -> None:
32
+ self.lang = lang
33
+
34
+ @staticmethod
35
+ def from_locales_dirs(lang: str):
36
+ locales_dirs = find_all_locales_dirs()
37
+ catalog: dict[str, dict[str, Any]] = {}
38
+
39
+ for locales_dir in locales_dirs if isinstance(locales_dirs, list) else [locales_dirs]:
40
+ for json_file in Path(locales_dir).glob('**/*.json'):
41
+ locale_name = json_file.parent.name
42
+
43
+ with open(json_file, 'r', encoding='utf-8') as f:
44
+ data = json.load(f)
45
+
46
+ catalog.setdefault(locale_name, {})
47
+
48
+ if isinstance(data, dict):
49
+ for v in data.values():
50
+ if isinstance(v, dict):
51
+ catalog[locale_name].update(v)
52
+
53
+ for k, v in data.items():
54
+ if isinstance(v, str):
55
+ catalog[locale_name][k] = v
56
+
57
+ return I18n(catalog, lang)
botflow/logger.py ADDED
@@ -0,0 +1,45 @@
1
+ import logging
2
+ from datetime import datetime as dt
3
+ from logging.handlers import TimedRotatingFileHandler
4
+ from pathlib import Path
5
+
6
+
7
+ def configure_logger(
8
+ name: str = 'big_views',
9
+ log_dir: str | Path = './logs',
10
+ level: int = logging.INFO,
11
+ console: bool = True,
12
+ rotating_file: bool = True,
13
+ ) -> logging.Logger:
14
+ logger = logging.getLogger(name)
15
+ logger.setLevel(level)
16
+ logger.propagate = False
17
+
18
+ log_dir = Path(log_dir)
19
+ log_dir.mkdir(parents=True, exist_ok=True)
20
+
21
+ fmt = logging.Formatter('%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s')
22
+
23
+ if rotating_file:
24
+ log_name = dt.now().strftime('%d-%m-%Y_%Hh-%Mm-%Ss')
25
+ log_path = log_dir / f'{log_name}.log'
26
+
27
+ file_handler = TimedRotatingFileHandler(
28
+ filename=str(log_path),
29
+ when='midnight',
30
+ interval=1,
31
+ backupCount=7,
32
+ encoding='utf-8',
33
+ )
34
+ file_handler.suffix = '%Y%m%d'
35
+ file_handler.setLevel(level)
36
+ file_handler.setFormatter(fmt)
37
+ logger.addHandler(file_handler)
38
+
39
+ if console:
40
+ console_handler = logging.StreamHandler()
41
+ console_handler.setLevel(level)
42
+ console_handler.setFormatter(fmt)
43
+ logger.addHandler(console_handler)
44
+
45
+ return logger
botflow/manager.py ADDED
@@ -0,0 +1,318 @@
1
+ import os
2
+ from logging import Logger
3
+ from typing import Any, Optional
4
+
5
+ from PySide6.QtCore import QThread, Slot
6
+ from PySide6.QtGui import QCloseEvent, QIcon, Qt
7
+ from PySide6.QtWidgets import (
8
+ QHBoxLayout,
9
+ QMessageBox,
10
+ QPushButton,
11
+ QSizePolicy,
12
+ QSpacerItem,
13
+ QStackedWidget,
14
+ QVBoxLayout,
15
+ QWidget,
16
+ )
17
+
18
+ from botflow.i18n import I18n
19
+ from botflow.logger import configure_logger
20
+ from botflow.pages import InitialPage, LoadingPage
21
+ from botflow.qss import qss_to_string
22
+ from botflow.resolver import find_resource_file
23
+ from botflow.runtime import get_lang
24
+ from botflow.types import FinishFn, FlowSpec, LoadingAbstract, StepSpec, WidgetAbstract
25
+ from botflow.workers import AsyncLoopThreadWorker, PipelineWorker
26
+
27
+
28
+ class FlowManager(QWidget):
29
+ ROOT_INITIAL = 'initial'
30
+ ROOT_WIZARD = 'wizard'
31
+ ROOT_LOADING = 'loading'
32
+
33
+ def __init__(
34
+ self,
35
+ flow: FlowSpec,
36
+ logger: Optional[Logger] = None,
37
+ icon_path: Optional[str] = None,
38
+ ):
39
+ super().__init__()
40
+ self.flow = flow
41
+ self.logger = logger if logger else configure_logger()
42
+
43
+ self.lang = get_lang() or 'en_US'
44
+ self.i18n = I18n.from_locales_dirs(self.lang)
45
+
46
+ self.context: dict[str, Any] = {}
47
+ self.steps: list[StepSpec] = []
48
+ self.pipeline: list[FinishFn] = []
49
+
50
+ self._async_loop = AsyncLoopThreadWorker()
51
+ self._async_loop.start()
52
+
53
+ self._thread: Optional[QThread] = None
54
+ self._worker: Optional[PipelineWorker] = None
55
+
56
+ self._set_style()
57
+
58
+ self.wizard_page = QWidget()
59
+ self.wizard_page.setObjectName('wizard')
60
+ self._wizard_layout = QVBoxLayout(self.wizard_page)
61
+
62
+ self.stack = QStackedWidget()
63
+ self.back_btn = QPushButton(self.i18n.t('common.back'))
64
+ self.next_btn = QPushButton(self.i18n.t('common.next'))
65
+ self.back_btn.setProperty('role', 'nav')
66
+ self.next_btn.setProperty('role', 'primary')
67
+
68
+ self.back_btn.clicked.connect(self.back)
69
+ self.next_btn.clicked.connect(self.foward)
70
+
71
+ if icon_path and os.path.exists(icon_path):
72
+ qicon = QIcon(icon_path)
73
+ self.setWindowIcon(qicon)
74
+
75
+ self.initial_page = self.create_initial_page()
76
+ self.loading_page = self.create_loading_page()
77
+
78
+ self._build_wizard_ui()
79
+ self.root_stack = QStackedWidget()
80
+ self._root_pages: dict[str, int] = {}
81
+ self.register_root_pages()
82
+
83
+ layout = QVBoxLayout(self)
84
+ layout.addWidget(self.root_stack)
85
+
86
+ self.load_flow(self.flow)
87
+ self.set_root_page(self.ROOT_INITIAL)
88
+
89
+ def _set_style(self):
90
+ style_file = find_resource_file('styles/flow_manager.qss')
91
+ qss_string = qss_to_string(style_file)
92
+ self.setStyleSheet(qss_string)
93
+
94
+ def _build_wizard_ui(self) -> None:
95
+ nav_container = QWidget()
96
+ nav_container.setObjectName('nav_container')
97
+ nav = QHBoxLayout(nav_container)
98
+ nav.setContentsMargins(0, 0, 0, 0)
99
+ nav.addWidget(self.back_btn)
100
+ nav.addStretch()
101
+ nav.addWidget(self.next_btn)
102
+
103
+ nav_divider = QWidget()
104
+ nav_divider.setObjectName('nav_divider')
105
+ nav_divider.setFixedHeight(1)
106
+
107
+ center_container = QWidget()
108
+ center_layout = QVBoxLayout(center_container)
109
+ center_layout.setContentsMargins(0, 0, 0, 0)
110
+ center_layout.addWidget(self.stack, 1)
111
+ center_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
112
+ self._wizard_layout.addWidget(center_container, 1)
113
+
114
+ self._wizard_layout.addSpacerItem(
115
+ QSpacerItem(0, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
116
+ )
117
+ self._wizard_layout.addWidget(nav_divider, 0)
118
+ self._wizard_layout.addSpacerItem(
119
+ QSpacerItem(0, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
120
+ )
121
+ self._wizard_layout.addWidget(nav_container, 0)
122
+
123
+ def create_initial_page(self) -> InitialPage:
124
+ return InitialPage(self.flow.name, self.go_to_wizard_page, self.i18n)
125
+
126
+ def create_loading_page(self) -> LoadingAbstract:
127
+ return LoadingPage(self.i18n)
128
+
129
+ def current_index(self) -> int:
130
+ return self.stack.currentIndex()
131
+
132
+ def current_spec(self) -> StepSpec:
133
+ return self.steps[self.current_index()]
134
+
135
+ def page_kwargs(self, spec: StepSpec) -> dict[str, Any]:
136
+ return {'spec': spec, 'i18n': self.i18n}
137
+
138
+ def make_page(self, spec: StepSpec):
139
+ return spec.widget_cls(**self.page_kwargs(spec))
140
+
141
+ def get_page_value(self, widget: WidgetAbstract) -> Any:
142
+ return widget.value()
143
+
144
+ def register_root_pages(self) -> None:
145
+ self.add_root_page(self.ROOT_INITIAL, self.initial_page)
146
+ self.add_root_page(self.ROOT_WIZARD, self.wizard_page)
147
+ self.add_root_page(self.ROOT_LOADING, self.loading_page)
148
+
149
+ def add_root_page(self, name: str, page: QWidget) -> int:
150
+ idx = self.root_stack.addWidget(page)
151
+ self._root_pages[name] = idx
152
+ return idx
153
+
154
+ def set_root_page(self, name: str) -> None:
155
+ self.root_stack.setCurrentIndex(self._root_pages[name])
156
+
157
+ def load_flow(self, flow: FlowSpec) -> None:
158
+ self.context = {}
159
+ self.pipeline = list(flow.on_finish)
160
+ self.steps = list(flow.steps)
161
+ self.rebuild_pages(go_to=0)
162
+
163
+ def rebuild_pages(self, go_to: int = 0) -> None:
164
+ while self.stack.count():
165
+ w = self.stack.widget(0)
166
+ self.stack.removeWidget(w)
167
+ w.deleteLater()
168
+
169
+ for spec in self.steps:
170
+ self.stack.addWidget(self.make_page(spec))
171
+
172
+ self.stack.setCurrentIndex(max(0, min(go_to, self.stack.count() - 1)))
173
+ self.update_nav()
174
+
175
+ def update_nav(self) -> None:
176
+ i = self.current_index()
177
+ self.back_btn.setEnabled(i > 0)
178
+ last = self.stack.count() > 0 and i == self.stack.count() - 1
179
+ text = self.i18n.t('common.start') if last else self.i18n.t('common.next')
180
+ self.next_btn.setText(text)
181
+
182
+ def validate_step(self, spec: StepSpec, value: Any) -> tuple[bool, str]:
183
+ if spec.validator:
184
+ return spec.validator(value)
185
+ return True, ''
186
+
187
+ def confirm_run(self) -> bool:
188
+ reply = QMessageBox(self)
189
+ reply.setWindowTitle(self.i18n.t('dialogs.confirm.title'))
190
+ reply.setText(self.i18n.t('dialogs.confirm.run_pipeline_text'))
191
+
192
+ yes_button = reply.addButton(
193
+ self.i18n.t('dialogs.confirm.yes'), QMessageBox.ButtonRole.YesRole
194
+ )
195
+ no_button = reply.addButton(
196
+ self.i18n.t('dialogs.confirm.no'), QMessageBox.ButtonRole.NoRole
197
+ )
198
+ reply.setDefaultButton(no_button)
199
+ reply.setIcon(QMessageBox.Icon.Question)
200
+ reply.exec()
201
+
202
+ return reply.clickedButton() == yes_button
203
+
204
+ def show_warn(self, msg: str) -> None:
205
+ QMessageBox.warning(self, self.i18n.t('dialogs.warn_title'), msg)
206
+
207
+ def show_success(self, msg: str) -> None:
208
+ QMessageBox.information(self, self.i18n.t('dialogs.success_title'), msg)
209
+
210
+ def show_error(self, msg: str) -> None:
211
+ QMessageBox.critical(self, self.i18n.t('dialogs.error_title'), msg)
212
+
213
+ def back(self) -> None:
214
+ i = self.current_index()
215
+ if i > 0:
216
+ self.stack.setCurrentIndex(i - 1)
217
+ self.update_nav()
218
+
219
+ def foward(self) -> None:
220
+ spec = self.current_spec()
221
+ page = self.stack.currentWidget()
222
+ val = self.get_page_value(page) if isinstance(page, WidgetAbstract) else None
223
+
224
+ ok, err = self.validate_step(spec, val)
225
+ if not ok:
226
+ self.show_warn(err)
227
+ return
228
+
229
+ self.context[spec.key] = val
230
+
231
+ if self.current_index() == self.stack.count() - 1:
232
+ can_run = self.confirm_run()
233
+
234
+ if not can_run:
235
+ return
236
+
237
+ self.run_pipeline_threaded()
238
+ return
239
+
240
+ self.stack.setCurrentIndex(self.current_index() + 1)
241
+ self.update_nav()
242
+
243
+ def run_pipeline_threaded(self) -> None:
244
+ if not self.pipeline:
245
+ self.show_success(self.i18n.t('messages.no_pipeline'))
246
+ return
247
+
248
+ self.next_btn.setEnabled(False)
249
+ self.back_btn.setEnabled(False)
250
+
251
+ self.set_root_page(self.ROOT_LOADING)
252
+
253
+ ctx_copy = self.context.copy()
254
+
255
+ self._thread = QThread(self)
256
+ self._worker = PipelineWorker(ctx_copy, self.pipeline, self.logger, self._async_loop)
257
+ self._worker.moveToThread(self._thread)
258
+
259
+ self._thread.started.connect(self._worker.run)
260
+ self._worker.progress.connect(self.loading_page.set_progress)
261
+ self._worker.status.connect(self.loading_page.set_status)
262
+ self._worker.finished.connect(self.on_finished)
263
+ self._worker.error.connect(self.on_error)
264
+
265
+ self._worker.finished.connect(self._thread.quit)
266
+ self._worker.error.connect(self._thread.quit)
267
+ self._thread.finished.connect(self._worker.deleteLater)
268
+ self._thread.finished.connect(self._thread.deleteLater)
269
+
270
+ self._thread.start()
271
+
272
+ @Slot(dict)
273
+ def on_finished(self, ctx: dict[str, Any]) -> None:
274
+ self.context.update(ctx)
275
+ self.next_btn.setEnabled(True)
276
+ self.back_btn.setEnabled(True)
277
+ self.show_success(self.i18n.t('messages.flow_success'))
278
+ self.restart_to_beginning()
279
+
280
+ @Slot(str)
281
+ def on_error(self, error_msg: str) -> None:
282
+ self.next_btn.setEnabled(True)
283
+ self.back_btn.setEnabled(True)
284
+ prefix = self.i18n.t('messages.flow_error_prefix')
285
+ self.show_error(prefix + error_msg)
286
+ self.restart_to_beginning()
287
+
288
+ def restart_to_beginning(self) -> None:
289
+ self.load_flow(self.flow)
290
+ self.set_root_page(self.ROOT_WIZARD)
291
+
292
+ def go_to_wizard_page(self) -> None:
293
+ self.set_root_page(self.ROOT_WIZARD)
294
+
295
+ def closeEvent(self, event: QCloseEvent): # noqa: N802
296
+ if not event.spontaneous():
297
+ event.accept()
298
+ return
299
+
300
+ reply = QMessageBox(self)
301
+ reply.setWindowTitle(self.i18n.t('dialogs.close.title'))
302
+ reply.setText(self.i18n.t('dialogs.close.exit_text'))
303
+ yes_button = reply.addButton(
304
+ self.i18n.t('dialogs.confirm.yes'), QMessageBox.ButtonRole.YesRole
305
+ )
306
+ no_button = reply.addButton(
307
+ self.i18n.t('dialogs.confirm.no'), QMessageBox.ButtonRole.NoRole
308
+ )
309
+ reply.setDefaultButton(no_button)
310
+ reply.setIcon(QMessageBox.Icon.Question)
311
+
312
+ reply.exec()
313
+
314
+ if reply.clickedButton() != yes_button:
315
+ event.ignore()
316
+ return
317
+
318
+ event.accept()
botflow/pages.py ADDED
@@ -0,0 +1,117 @@
1
+ from typing import Callable
2
+
3
+ from PySide6.QtCore import QSize, Qt, Slot
4
+ from PySide6.QtGui import QMovie
5
+ from PySide6.QtWidgets import (
6
+ QHBoxLayout,
7
+ QLabel,
8
+ QProgressBar,
9
+ QPushButton,
10
+ QSizePolicy,
11
+ QSpacerItem,
12
+ QVBoxLayout,
13
+ QWidget,
14
+ )
15
+
16
+ from botflow.qss import qss_to_string
17
+ from botflow.resolver import find_resource_file
18
+ from botflow.types import I18n, LoadingAbstract
19
+
20
+
21
+ class InitialPage(QWidget):
22
+ def __init__(self, name: str, on_start: Callable, i18n: I18n):
23
+ super().__init__()
24
+ style_file = find_resource_file('styles/initial_page.qss')
25
+ qss_string = qss_to_string(style_file)
26
+ self.setStyleSheet(qss_string)
27
+
28
+ title_label = QLabel(name)
29
+ title_label.setProperty('role', 'initial_title')
30
+ title_label.setWordWrap(True)
31
+ title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
32
+
33
+ row = QHBoxLayout()
34
+ row.setContentsMargins(0, 0, 0, 0)
35
+ row.addWidget(title_label, alignment=Qt.AlignmentFlag.AlignVCenter)
36
+
37
+ row_container = QWidget()
38
+ row_container.setLayout(row)
39
+
40
+ start_text = i18n.t('common.start') if i18n else 'Start'
41
+ start_btn = QPushButton(start_text)
42
+ start_btn.setProperty('role', 'initial_button')
43
+ start_btn.setCursor(Qt.CursorShape.PointingHandCursor)
44
+ if on_start:
45
+ start_btn.clicked.connect(on_start)
46
+
47
+ main_layout = QVBoxLayout(self)
48
+ main_layout.setContentsMargins(0, 0, 0, 0)
49
+ main_layout.addStretch()
50
+ main_layout.addWidget(row_container, alignment=Qt.AlignmentFlag.AlignCenter)
51
+ main_layout.addSpacerItem(
52
+ QSpacerItem(
53
+ 0,
54
+ 58,
55
+ QSizePolicy.Policy.Minimum,
56
+ QSizePolicy.Policy.Fixed,
57
+ )
58
+ )
59
+ main_layout.addWidget(start_btn, alignment=Qt.AlignmentFlag.AlignCenter)
60
+ main_layout.addStretch()
61
+
62
+
63
+ class LoadingPage(LoadingAbstract):
64
+ def __init__(self, i18n: I18n):
65
+ super().__init__()
66
+ style_file = find_resource_file('styles/loading_page.qss')
67
+ qss_string = qss_to_string(style_file)
68
+ self.setStyleSheet(qss_string)
69
+
70
+ loading_gif = find_resource_file('assets/loading.gif')
71
+ gif_label = QLabel(self)
72
+ gif_label.setProperty('role', 'loading_icon')
73
+ movie = QMovie(loading_gif.as_posix())
74
+ movie.setScaledSize(QSize(64, 64))
75
+ gif_label.setMovie(movie)
76
+ movie.start()
77
+
78
+ title_text = i18n.t('loading.title') if i18n else 'In Progress...'
79
+ title_lbl = QLabel(title_text, self)
80
+ title_lbl.setProperty('role', 'loading_title')
81
+
82
+ header = QHBoxLayout()
83
+ header.setAlignment(Qt.AlignmentFlag.AlignCenter)
84
+ header.addWidget(gif_label)
85
+ header.addSpacing(5)
86
+ header.addWidget(title_lbl)
87
+
88
+ self.status_lbl = QLabel('', self)
89
+ self.status_lbl.setProperty('role', 'loading_status')
90
+
91
+ self.progress_bar = QProgressBar(self)
92
+ self.progress_bar.setRange(0, 100)
93
+ self.progress_bar.setTextVisible(False)
94
+ self.progress_bar.setProperty('role', 'loading_progress')
95
+
96
+ progress_col = QVBoxLayout()
97
+ progress_col.setAlignment(Qt.AlignmentFlag.AlignCenter)
98
+ progress_col.setSpacing(6)
99
+ progress_col.addWidget(self.status_lbl, alignment=Qt.AlignmentFlag.AlignCenter)
100
+ progress_col.addWidget(self.progress_bar)
101
+
102
+ layout = QVBoxLayout(self)
103
+ layout.setContentsMargins(0, 24, 0, 0)
104
+ layout.addLayout(header)
105
+ layout.addSpacerItem(
106
+ QSpacerItem(0, 32, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
107
+ )
108
+ layout.addLayout(progress_col)
109
+ layout.addStretch()
110
+
111
+ @Slot(int)
112
+ def set_progress(self, value: int):
113
+ self.progress_bar.setValue(max(0, min(value, 100)))
114
+
115
+ @Slot(str)
116
+ def set_status(self, text: str):
117
+ self.status_lbl.setText(text)
botflow/qss.py ADDED
@@ -0,0 +1,21 @@
1
+ from pathlib import Path
2
+ from typing import Union
3
+
4
+ PathLike = Union[str, Path]
5
+
6
+
7
+ def qss_to_string(*paths: PathLike) -> str:
8
+ parts: list[str] = []
9
+
10
+ for p in paths:
11
+ p = Path(p)
12
+
13
+ if p.suffix != '.qss':
14
+ raise ValueError('File must have a .qss extension.')
15
+
16
+ if not p.is_file():
17
+ raise FileNotFoundError(f'The file {p} does not exist.')
18
+
19
+ parts.append(p.read_text(encoding='utf-8'))
20
+
21
+ return '\n'.join(parts) + '\n'