fry-qt6-progress-widget 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.
@@ -0,0 +1,16 @@
1
+ from .dock import GeneralTaskProgressDock
2
+ from .progress_widget import GeneralTaskProgressWidget, InfoTypeConfig, ProgressResult
3
+
4
+ __version__ = "0.1.0"
5
+
6
+ TaskProgressWidget = GeneralTaskProgressWidget
7
+ TaskProgressDock = GeneralTaskProgressDock
8
+
9
+ __all__ = [
10
+ "GeneralTaskProgressWidget",
11
+ "GeneralTaskProgressDock",
12
+ "TaskProgressWidget",
13
+ "TaskProgressDock",
14
+ "InfoTypeConfig",
15
+ "ProgressResult",
16
+ ]
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Mapping, Optional
4
+
5
+ from PyQt6.QtCore import Qt, pyqtSlot
6
+ from PyQt6.QtWidgets import QDockWidget, QWidget
7
+
8
+ from .progress_widget import GeneralTaskProgressWidget, ProgressResult
9
+
10
+
11
+ class GeneralTaskProgressDock(QDockWidget):
12
+ """Thin QDockWidget wrapper around GeneralTaskProgressWidget."""
13
+
14
+ def __init__(
15
+ self,
16
+ parent: Optional[QWidget] = None,
17
+ *,
18
+ title: str = "任务进度",
19
+ widget: Optional[GeneralTaskProgressWidget] = None,
20
+ allowed_areas: Optional[Qt.DockWidgetArea] = None,
21
+ **widget_kwargs: Any,
22
+ ) -> None:
23
+ super().__init__(title, parent)
24
+ self.progress_widget = widget or GeneralTaskProgressWidget(parent=self, title=title, **widget_kwargs)
25
+ self.setWidget(self.progress_widget)
26
+ self.setAllowedAreas(
27
+ allowed_areas
28
+ or (Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea)
29
+ )
30
+
31
+ def begin_task(self, *args: Any, **kwargs: Any) -> ProgressResult:
32
+ return self.progress_widget.begin_task(*args, **kwargs)
33
+
34
+ def update_progress(self, *args: Any, **kwargs: Any) -> ProgressResult:
35
+ return self.progress_widget.update_progress(*args, **kwargs)
36
+
37
+ def finish_task(self, *args: Any, **kwargs: Any) -> ProgressResult:
38
+ return self.progress_widget.finish_task(*args, **kwargs)
39
+
40
+ def append_message(self, *args: Any, **kwargs: Any) -> ProgressResult:
41
+ return self.progress_widget.append_message(*args, **kwargs)
42
+
43
+ def clear_info(self) -> ProgressResult:
44
+ return self.progress_widget.clear_info()
45
+
46
+ def set_paused_state(self, is_paused: bool) -> ProgressResult:
47
+ return self.progress_widget.set_paused_state(is_paused)
48
+
49
+ def set_project_filter(self, project_id: Optional[str]) -> ProgressResult:
50
+ return self.progress_widget.set_project_filter(project_id)
51
+
52
+ def get_params_dict(self) -> Dict[str, Any]:
53
+ return self.progress_widget.get_params_dict()
54
+
55
+ def load_params_dict(self, params_dict: Mapping[str, Any]) -> ProgressResult:
56
+ return self.progress_widget.load_params_dict(params_dict)
57
+
58
+ def fry_reload(self, params_dict: Optional[Mapping[str, Any]] = None) -> ProgressResult:
59
+ return self.progress_widget.fry_reload(params_dict)
60
+
61
+ def fry_reset(self) -> ProgressResult:
62
+ return self.progress_widget.fry_reset()
63
+
64
+ @pyqtSlot(str, str)
65
+ def handle_task_begin(self, project_id: str, total_task_name: str) -> ProgressResult:
66
+ return self.progress_widget.handle_task_begin(project_id, total_task_name)
67
+
68
+ @pyqtSlot(dict)
69
+ def handle_task_progress(self, task_data_dict: Dict[str, Any]) -> ProgressResult:
70
+ return self.progress_widget.handle_task_progress(task_data_dict)
71
+
72
+ @pyqtSlot(str)
73
+ def handle_task_finished(self, project_id: str) -> ProgressResult:
74
+ return self.progress_widget.handle_task_finished(project_id)
@@ -0,0 +1,743 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from html import escape
6
+ import math
7
+ import time
8
+ from typing import Any, Callable, ClassVar, Dict, Mapping, Optional, Tuple
9
+
10
+ from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot
11
+ from PyQt6.QtGui import QTextCursor
12
+ from PyQt6.QtWidgets import (
13
+ QFrame,
14
+ QHBoxLayout,
15
+ QLabel,
16
+ QProgressBar,
17
+ QScrollArea,
18
+ QTextEdit,
19
+ QVBoxLayout,
20
+ QWidget,
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ProgressResult:
26
+ """Small result object returned by public mutating methods."""
27
+
28
+ status: str
29
+ message: str = ""
30
+ data: Optional[Dict[str, Any]] = None
31
+
32
+ @property
33
+ def ok(self) -> bool:
34
+ return self.status != "failed"
35
+
36
+ @classmethod
37
+ def success(cls, message: str = "", data: Optional[Dict[str, Any]] = None) -> "ProgressResult":
38
+ return cls(status="success", message=message, data=data)
39
+
40
+ @classmethod
41
+ def info(cls, message: str = "", data: Optional[Dict[str, Any]] = None) -> "ProgressResult":
42
+ return cls(status="info", message=message, data=data)
43
+
44
+ @classmethod
45
+ def warning(cls, message: str = "", data: Optional[Dict[str, Any]] = None) -> "ProgressResult":
46
+ return cls(status="warning", message=message, data=data)
47
+
48
+ @classmethod
49
+ def failed(cls, message: str = "", data: Optional[Dict[str, Any]] = None) -> "ProgressResult":
50
+ return cls(status="failed", message=message, data=data)
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class InfoTypeConfig:
55
+ """Display colors for detail messages."""
56
+
57
+ color: str
58
+ icon: str
59
+ icon_color: str
60
+
61
+ TYPE_CONFIGS: ClassVar[Dict[str, Tuple[str, str, str]]] = {
62
+ "信息": ("#1F2937", "info-circle", "#4C566A"),
63
+ "成功": ("#2E7D32", "check-circle", "#2E7D32"),
64
+ "失败": ("#C62828", "times-circle", "#C62828"),
65
+ "异常": ("#C62828", "exclamation-triangle", "#C62828"),
66
+ "警告": ("#B45309", "exclamation-circle", "#B45309"),
67
+ "调试": ("#6D28D9", "bug", "#6D28D9"),
68
+ "重要": ("#1D4ED8", "star", "#1D4ED8"),
69
+ "info": ("#1F2937", "info-circle", "#4C566A"),
70
+ "success": ("#2E7D32", "check-circle", "#2E7D32"),
71
+ "error": ("#C62828", "times-circle", "#C62828"),
72
+ "warning": ("#B45309", "exclamation-circle", "#B45309"),
73
+ "debug": ("#6D28D9", "bug", "#6D28D9"),
74
+ }
75
+
76
+ @classmethod
77
+ def get_config(cls, info_type: str) -> "InfoTypeConfig":
78
+ config = cls.TYPE_CONFIGS.get(info_type, cls.TYPE_CONFIGS["信息"])
79
+ return cls(color=config[0], icon=config[1], icon_color=config[2])
80
+
81
+
82
+ class GeneralTaskProgressWidget(QWidget):
83
+ """A reusable PyQt6 widget for showing long-running task progress."""
84
+
85
+ taskStarted = pyqtSignal(str, str)
86
+ progressChanged = pyqtSignal(dict)
87
+ taskFinished = pyqtSignal(str)
88
+ messageAppended = pyqtSignal(str, str)
89
+ pausedChanged = pyqtSignal(bool)
90
+
91
+ def __init__(
92
+ self,
93
+ parent: Optional[QWidget] = None,
94
+ *,
95
+ title: str = "任务进度",
96
+ accepted_project_id: Optional[str] = None,
97
+ auto_show: bool = False,
98
+ max_detail_blocks: int = 500,
99
+ logger: Optional[Callable[[str, str], None]] = None,
100
+ clock: Optional[Callable[[], float]] = None,
101
+ ) -> None:
102
+ super().__init__(parent)
103
+ self.title = title
104
+ self.accepted_project_id = accepted_project_id
105
+ self.auto_show = auto_show
106
+ self.max_detail_blocks = max_detail_blocks
107
+ self.logger = logger
108
+ self._clock = clock or time.monotonic
109
+
110
+ self.current_project_id = ""
111
+ self.current_task_name = ""
112
+ self.current_progress = 0
113
+ self.total_progress = 0
114
+ self.is_paused = False
115
+ self._task_time_records: Dict[str, Dict[str, Any]] = {}
116
+ # 滑动窗口大小:用最近这么多个进度样本估算吞吐率(越大越平滑,越小越灵敏)
117
+ self._eta_window_size = 20
118
+
119
+ self.setObjectName("FryTaskProgressWidget")
120
+ self._setup_ui()
121
+ self._apply_styles()
122
+ self.clear_info()
123
+
124
+ def set_project_filter(self, project_id: Optional[str]) -> ProgressResult:
125
+ """Accept only updates for project_id. Pass None to accept all projects."""
126
+ self.accepted_project_id = project_id
127
+ return ProgressResult.success("项目过滤器已更新")
128
+
129
+ def begin_task(
130
+ self,
131
+ project_id: str = "",
132
+ task_name: str = "任务",
133
+ total: Optional[Any] = None,
134
+ *,
135
+ clear: bool = True,
136
+ ) -> ProgressResult:
137
+ """Start a task and reset the visible progress state."""
138
+ try:
139
+ if not self._should_accept_project(project_id):
140
+ return ProgressResult.info("忽略其他项目的任务开始信号")
141
+
142
+ if clear:
143
+ self.clear_info()
144
+
145
+ self.current_project_id = str(project_id or "")
146
+ self.current_task_name = str(task_name or "任务")
147
+ self.current_progress = 0
148
+ self.total_progress = self._coerce_int(total, 0)
149
+ self.is_paused = False
150
+ self._task_time_records.clear()
151
+
152
+ self.project_id_value.setText(self.current_project_id or "无")
153
+ self.task_name_value.setText(self.current_task_name)
154
+ self._set_status("running", "运行中")
155
+ self._render_progress()
156
+ self._append_detail_info("信息", f"任务开始: {self.current_task_name}", log=False)
157
+
158
+ if self.auto_show:
159
+ self.show()
160
+
161
+ self.taskStarted.emit(self.current_project_id, self.current_task_name)
162
+ self.progressChanged.emit(self.get_params_dict())
163
+ return ProgressResult.success("任务已开始", self.get_params_dict())
164
+ except Exception as exc: # pragma: no cover - defensive Qt boundary
165
+ return ProgressResult.failed(f"任务开始失败: {exc}")
166
+
167
+ def update_progress(
168
+ self,
169
+ current: Optional[Any] = None,
170
+ total: Optional[Any] = None,
171
+ *,
172
+ task_name: Optional[str] = None,
173
+ project_id: Optional[str] = None,
174
+ info_type: str = "信息",
175
+ detail_info: str = "",
176
+ ) -> ProgressResult:
177
+ """Update progress and optionally append one detail message."""
178
+ try:
179
+ resolved_project_id = self.current_project_id if project_id is None else str(project_id or "")
180
+ if not self._should_accept_project(resolved_project_id):
181
+ return ProgressResult.info("忽略其他项目的任务进度")
182
+
183
+ if resolved_project_id:
184
+ self.current_project_id = resolved_project_id
185
+ self.project_id_value.setText(resolved_project_id)
186
+
187
+ if task_name:
188
+ self.current_task_name = str(task_name)
189
+ self.task_name_value.setText(self.current_task_name)
190
+
191
+ if total is not None:
192
+ self.total_progress = self._coerce_int(total, self.total_progress)
193
+
194
+ if current is not None:
195
+ self.current_progress = self._coerce_int(current, self.current_progress)
196
+
197
+ self.current_progress = max(0, self.current_progress)
198
+ self.total_progress = max(0, self.total_progress)
199
+ if self.total_progress:
200
+ self.current_progress = min(self.current_progress, self.total_progress)
201
+
202
+ self._set_status("paused" if self.is_paused else "running", "已暂停" if self.is_paused else "运行中")
203
+ self._render_progress()
204
+ self._update_time_estimate()
205
+
206
+ if detail_info:
207
+ self._append_detail_info(info_type, detail_info)
208
+
209
+ if self.auto_show:
210
+ self.show()
211
+
212
+ self.progressChanged.emit(self.get_params_dict())
213
+ return ProgressResult.success("任务进度已更新", self.get_params_dict())
214
+ except Exception as exc: # pragma: no cover - defensive Qt boundary
215
+ self._log("异常", f"处理任务进度更新失败: {exc}")
216
+ return ProgressResult.failed(f"任务进度更新失败: {exc}")
217
+
218
+ def finish_task(
219
+ self,
220
+ project_id: Optional[str] = None,
221
+ *,
222
+ message: str = "任务已完成",
223
+ hide: bool = False,
224
+ ) -> ProgressResult:
225
+ """Mark the current task as finished."""
226
+ try:
227
+ resolved_project_id = self.current_project_id if project_id is None else str(project_id or "")
228
+ if not self._should_accept_project(resolved_project_id):
229
+ return ProgressResult.info("忽略其他项目的任务完成信号")
230
+
231
+ if self.total_progress > 0:
232
+ self.current_progress = self.total_progress
233
+
234
+ self.is_paused = False
235
+ self._set_status("success", "已完成")
236
+ self._render_progress()
237
+ self._update_time_estimate(force_done=True)
238
+ self._append_detail_info("成功", message)
239
+ self._task_time_records.clear()
240
+
241
+ if hide:
242
+ self.hide()
243
+
244
+ self.taskFinished.emit(resolved_project_id)
245
+ self.progressChanged.emit(self.get_params_dict())
246
+ return ProgressResult.success("任务已完成", self.get_params_dict())
247
+ except Exception as exc: # pragma: no cover - defensive Qt boundary
248
+ return ProgressResult.failed(f"任务完成处理失败: {exc}")
249
+
250
+ def append_message(self, info_type: str, message: str) -> ProgressResult:
251
+ """Append a message to the detail area."""
252
+ return self._append_detail_info(info_type, message)
253
+
254
+ def clear_info(self) -> ProgressResult:
255
+ """Reset all visible values."""
256
+ try:
257
+ self.current_project_id = ""
258
+ self.current_task_name = ""
259
+ self.current_progress = 0
260
+ self.total_progress = 0
261
+ self.is_paused = False
262
+ self._task_time_records.clear()
263
+
264
+ self.project_id_value.setText("无")
265
+ self.task_name_value.setText("无")
266
+ self.progress_label.setText("进度: 0 / 0")
267
+ self.progress_percent.setText("0%")
268
+ self.progress_bar.setRange(0, 100)
269
+ self.progress_bar.setValue(0)
270
+ self.progress_bar.setStyleSheet("")
271
+ self.time_estimate_label.setText("")
272
+ self.detail_text.clear()
273
+ self._set_status("idle", "未开始")
274
+ return ProgressResult.success("信息已清空")
275
+ except Exception as exc: # pragma: no cover - defensive Qt boundary
276
+ return ProgressResult.failed(f"清空信息失败: {exc}")
277
+
278
+ def set_paused_state(self, is_paused: bool) -> ProgressResult:
279
+ """Switch between running and paused visual states."""
280
+ try:
281
+ self.is_paused = bool(is_paused)
282
+ if self.is_paused:
283
+ self._set_status("paused", "已暂停")
284
+ self._set_progress_bar_color("#D97706", "#F59E0B")
285
+ self._append_detail_info("警告", "任务已暂停")
286
+ else:
287
+ self._set_status("running", "运行中")
288
+ self.progress_bar.setStyleSheet("")
289
+ self._append_detail_info("信息", "任务已恢复")
290
+
291
+ self.pausedChanged.emit(self.is_paused)
292
+ self.progressChanged.emit(self.get_params_dict())
293
+ return ProgressResult.success(f"暂停状态已设置为: {self.is_paused}")
294
+ except Exception as exc: # pragma: no cover - defensive Qt boundary
295
+ return ProgressResult.failed(f"设置暂停状态失败: {exc}")
296
+
297
+ def get_params_dict(self) -> Dict[str, Any]:
298
+ """Return a serializable snapshot of the widget state."""
299
+ percent = self._current_percent()
300
+ return {
301
+ "current_project_id": self.current_project_id,
302
+ "project_id_value": self.project_id_value.text(),
303
+ "task_name_value": self.task_name_value.text(),
304
+ "current_progress": self.current_progress,
305
+ "total_progress": self.total_progress,
306
+ "progress_value": self.progress_bar.value(),
307
+ "progress_percent_num": percent,
308
+ "progress_label": self.progress_label.text(),
309
+ "progress_percent": self.progress_percent.text(),
310
+ "status": self.status_value.text(),
311
+ "is_paused": self.is_paused,
312
+ "time_estimate": self.time_estimate_label.text(),
313
+ "detail_text": self.detail_text.toHtml(),
314
+ "detail_plain_text": self.detail_text.toPlainText(),
315
+ }
316
+
317
+ def load_params_dict(self, params_dict: Mapping[str, Any]) -> ProgressResult:
318
+ """Restore a snapshot created by get_params_dict."""
319
+ try:
320
+ if not params_dict:
321
+ return ProgressResult.info("参数字典为空,跳过加载")
322
+
323
+ self.current_project_id = str(params_dict.get("current_project_id", self.current_project_id))
324
+ self.current_task_name = str(params_dict.get("task_name_value", self.current_task_name))
325
+ self.current_progress = self._coerce_int(params_dict.get("current_progress"), self.current_progress)
326
+ self.total_progress = self._coerce_int(params_dict.get("total_progress"), self.total_progress)
327
+ self.is_paused = bool(params_dict.get("is_paused", self.is_paused))
328
+
329
+ self.project_id_value.setText(str(params_dict.get("project_id_value", self.current_project_id or "无")))
330
+ self.task_name_value.setText(str(params_dict.get("task_name_value", self.current_task_name or "无")))
331
+ self.progress_label.setText(str(params_dict.get("progress_label", "进度: 0 / 0")))
332
+ self.progress_percent.setText(str(params_dict.get("progress_percent", "0%")))
333
+ self.time_estimate_label.setText(str(params_dict.get("time_estimate", "")))
334
+
335
+ progress_value = self._coerce_int(params_dict.get("progress_value"), 0)
336
+ self.progress_bar.setRange(0, 100)
337
+ self.progress_bar.setValue(max(0, min(progress_value, 100)))
338
+
339
+ if "detail_text" in params_dict:
340
+ self.detail_text.setHtml(str(params_dict["detail_text"]))
341
+
342
+ status_text = str(params_dict.get("status", "已暂停" if self.is_paused else "未开始"))
343
+ self._set_status("paused" if self.is_paused else "idle", status_text)
344
+ return ProgressResult.success("参数字典已加载")
345
+ except Exception as exc: # pragma: no cover - defensive Qt boundary
346
+ return ProgressResult.failed(f"加载参数字典失败: {exc}")
347
+
348
+ def fry_reload(self, params_dict: Optional[Mapping[str, Any]] = None) -> ProgressResult:
349
+ """Compatibility alias for applications that use fry_reload."""
350
+ if params_dict is None:
351
+ return self.fry_reset()
352
+ return self.load_params_dict(params_dict)
353
+
354
+ def fry_reset(self) -> ProgressResult:
355
+ """Compatibility alias for resetting the widget."""
356
+ return self.clear_info()
357
+
358
+ @pyqtSlot(str, str)
359
+ def handle_task_begin(self, project_id: str, total_task_name: str) -> ProgressResult:
360
+ """Qt slot compatible with the original dock implementation."""
361
+ return self.begin_task(project_id=project_id, task_name=total_task_name)
362
+
363
+ @pyqtSlot(dict)
364
+ def handle_task_progress(self, task_data_dict: Dict[str, Any]) -> ProgressResult:
365
+ """Qt slot accepting the original task progress dictionary keys."""
366
+ task_data_dict = task_data_dict or {}
367
+ project_id = task_data_dict.get("project_id_str", task_data_dict.get("project_id"))
368
+ task_name = task_data_dict.get("task_name_str", task_data_dict.get("task_name"))
369
+ current = task_data_dict.get("now_progress_num", task_data_dict.get("current_progress"))
370
+ total = task_data_dict.get("total_num", task_data_dict.get("total_progress"))
371
+ info_type = task_data_dict.get("info_type", "信息")
372
+ detail_info = task_data_dict.get("detail_info", task_data_dict.get("message", ""))
373
+ return self.update_progress(
374
+ current=current,
375
+ total=total,
376
+ task_name=task_name,
377
+ project_id=project_id,
378
+ info_type=info_type,
379
+ detail_info=detail_info,
380
+ )
381
+
382
+ @pyqtSlot(str)
383
+ def handle_task_finished(self, project_id: str) -> ProgressResult:
384
+ """Qt slot compatible with the original dock implementation."""
385
+ return self.finish_task(project_id=project_id)
386
+
387
+ def _setup_ui(self) -> None:
388
+ self.setMinimumWidth(320)
389
+
390
+ root_layout = QVBoxLayout(self)
391
+ root_layout.setContentsMargins(0, 0, 0, 0)
392
+ root_layout.setSpacing(0)
393
+
394
+ scroll_area = QScrollArea()
395
+ scroll_area.setWidgetResizable(True)
396
+ scroll_area.setFrameShape(QFrame.Shape.NoFrame)
397
+ scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
398
+ scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
399
+
400
+ main_widget = QWidget()
401
+ main_widget.setObjectName("FryProgressContent")
402
+ main_layout = QVBoxLayout(main_widget)
403
+ main_layout.setContentsMargins(12, 12, 12, 12)
404
+ main_layout.setSpacing(10)
405
+
406
+ title_label = QLabel(self.title)
407
+ title_label.setObjectName("FryProgressTitle")
408
+ main_layout.addWidget(title_label)
409
+
410
+ self._create_project_info_section(main_layout)
411
+ self._add_separator(main_layout)
412
+ self._create_task_info_section(main_layout)
413
+ self._add_separator(main_layout)
414
+ self._create_progress_section(main_layout)
415
+ self._add_separator(main_layout)
416
+ self._create_detail_info_section(main_layout)
417
+
418
+ main_layout.addStretch()
419
+ scroll_area.setWidget(main_widget)
420
+ root_layout.addWidget(scroll_area)
421
+
422
+ def _create_project_info_section(self, parent_layout: QVBoxLayout) -> None:
423
+ project_label = QLabel("项目 ID:")
424
+ project_label.setObjectName("FrySectionLabel")
425
+ self.project_id_value = QLabel("无")
426
+ self.project_id_value.setWordWrap(True)
427
+ self.project_id_value.setObjectName("FryValueLabel")
428
+ parent_layout.addWidget(project_label)
429
+ parent_layout.addWidget(self.project_id_value)
430
+
431
+ def _create_task_info_section(self, parent_layout: QVBoxLayout) -> None:
432
+ status_layout = QHBoxLayout()
433
+ status_label = QLabel("状态:")
434
+ status_label.setObjectName("FrySectionLabel")
435
+ self.status_value = QLabel("未开始")
436
+ self.status_value.setObjectName("FryStatusValue")
437
+ status_layout.addWidget(status_label)
438
+ status_layout.addWidget(self.status_value)
439
+ status_layout.addStretch()
440
+ parent_layout.addLayout(status_layout)
441
+
442
+ task_layout = QHBoxLayout()
443
+ task_label = QLabel("任务名称:")
444
+ task_label.setObjectName("FrySectionLabel")
445
+ self.task_name_value = QLabel("无")
446
+ self.task_name_value.setObjectName("FryValueLabel")
447
+ self.task_name_value.setWordWrap(True)
448
+ task_layout.addWidget(task_label)
449
+ task_layout.addWidget(self.task_name_value, 1)
450
+ parent_layout.addLayout(task_layout)
451
+
452
+ def _create_progress_section(self, parent_layout: QVBoxLayout) -> None:
453
+ progress_info_layout = QHBoxLayout()
454
+ self.progress_label = QLabel("进度: 0 / 0")
455
+ self.progress_label.setMinimumWidth(150)
456
+ self.progress_label.setObjectName("FrySectionLabel")
457
+
458
+ self.progress_percent = QLabel("0%")
459
+ self.progress_percent.setObjectName("FryPercentLabel")
460
+
461
+ progress_info_layout.addWidget(self.progress_label)
462
+ progress_info_layout.addStretch()
463
+ progress_info_layout.addWidget(self.progress_percent)
464
+ parent_layout.addLayout(progress_info_layout)
465
+
466
+ self.progress_bar = QProgressBar()
467
+ self.progress_bar.setRange(0, 100)
468
+ self.progress_bar.setValue(0)
469
+ self.progress_bar.setTextVisible(False)
470
+ self.progress_bar.setMinimumHeight(24)
471
+ parent_layout.addWidget(self.progress_bar)
472
+
473
+ self.time_estimate_label = QLabel("")
474
+ self.time_estimate_label.setObjectName("FryTimeEstimate")
475
+ self.time_estimate_label.setWordWrap(True)
476
+ parent_layout.addWidget(self.time_estimate_label)
477
+
478
+ def _create_detail_info_section(self, parent_layout: QVBoxLayout) -> None:
479
+ detail_label = QLabel("详细信息:")
480
+ detail_label.setObjectName("FrySectionLabel")
481
+ parent_layout.addWidget(detail_label)
482
+
483
+ self.detail_text = QTextEdit()
484
+ self.detail_text.setReadOnly(True)
485
+ self.detail_text.setMinimumHeight(150)
486
+ self.detail_text.setMaximumHeight(320)
487
+ self.detail_text.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
488
+ self.detail_text.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
489
+ self.detail_text.document().setMaximumBlockCount(max(0, int(self.max_detail_blocks)))
490
+ parent_layout.addWidget(self.detail_text)
491
+
492
+ def _add_separator(self, parent_layout: QVBoxLayout) -> None:
493
+ separator = QFrame()
494
+ separator.setFrameShape(QFrame.Shape.HLine)
495
+ separator.setFrameShadow(QFrame.Shadow.Sunken)
496
+ parent_layout.addWidget(separator)
497
+
498
+ def _apply_styles(self) -> None:
499
+ self.setStyleSheet(
500
+ """
501
+ QWidget#FryTaskProgressWidget,
502
+ QWidget#FryProgressContent {
503
+ background-color: #F8FAFC;
504
+ color: #111827;
505
+ font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
506
+ }
507
+ QLabel#FryProgressTitle {
508
+ color: #111827;
509
+ font-size: 16px;
510
+ font-weight: 700;
511
+ padding-bottom: 2px;
512
+ }
513
+ QLabel#FrySectionLabel {
514
+ color: #4B5563;
515
+ font-size: 12px;
516
+ font-weight: 700;
517
+ }
518
+ QLabel#FryValueLabel {
519
+ color: #111827;
520
+ font-size: 12px;
521
+ padding: 3px 0;
522
+ }
523
+ QLabel#FryPercentLabel {
524
+ color: #1D4ED8;
525
+ font-size: 12px;
526
+ font-weight: 700;
527
+ }
528
+ QLabel#FryTimeEstimate {
529
+ color: #2563EB;
530
+ font-size: 11px;
531
+ font-style: italic;
532
+ }
533
+ QLabel#FryStatusValue {
534
+ border-radius: 4px;
535
+ color: white;
536
+ font-size: 11px;
537
+ font-weight: 700;
538
+ padding: 2px 8px;
539
+ }
540
+ QScrollArea {
541
+ background-color: #F8FAFC;
542
+ border: none;
543
+ }
544
+ QProgressBar {
545
+ background-color: #E5E7EB;
546
+ border: 1px solid #CBD5E1;
547
+ border-radius: 5px;
548
+ text-align: center;
549
+ }
550
+ QProgressBar::chunk {
551
+ background-color: qlineargradient(
552
+ x1:0, y1:0, x2:1, y2:0,
553
+ stop:0 #2563EB,
554
+ stop:1 #38BDF8
555
+ );
556
+ border-radius: 4px;
557
+ }
558
+ QTextEdit {
559
+ background-color: #FFFFFF;
560
+ border: 1px solid #CBD5E1;
561
+ border-radius: 4px;
562
+ color: #111827;
563
+ padding: 5px;
564
+ }
565
+ QFrame {
566
+ color: #E5E7EB;
567
+ }
568
+ """
569
+ )
570
+
571
+ def _render_progress(self) -> None:
572
+ if self.total_progress <= 0:
573
+ self.progress_bar.setRange(0, 0)
574
+ self.progress_label.setText(f"进度: {self.current_progress} / 未知")
575
+ self.progress_percent.setText("计算中")
576
+ return
577
+
578
+ percent = self._current_percent()
579
+ self.progress_bar.setRange(0, 100)
580
+ self.progress_bar.setValue(int(round(percent)))
581
+ self.progress_label.setText(f"进度: {self.current_progress} / {self.total_progress}")
582
+ self.progress_percent.setText(f"{percent:g}%")
583
+
584
+ def _update_time_estimate(self, *, force_done: bool = False) -> None:
585
+ """更新预估时间。
586
+
587
+ 优化点:剩余时间用「滑动窗口内的最近吞吐率」估算,而非自起点的累计平均。
588
+ 当任务速度随时间变化时(前慢后快 / 前快后慢),窗口估计更灵敏、更接近真实。
589
+ 窗口不足时自动回退到累计平均。
590
+ """
591
+ task_key = self.current_task_name or "__default__"
592
+ now = self._clock()
593
+ cur = float(self.current_progress)
594
+ record = self._task_time_records.get(task_key)
595
+
596
+ if record is None:
597
+ self._task_time_records[task_key] = {
598
+ "first_time": now,
599
+ "first_progress": cur,
600
+ "samples": [(now, cur)], # 滑动窗口样本 (时间, 进度)
601
+ }
602
+ self.time_estimate_label.setText("正在计算预估时间...")
603
+ return
604
+
605
+ # 追加样本并裁剪窗口(最多保留最近 self._eta_window_size 个样本)
606
+ samples = record["samples"]
607
+ samples.append((now, cur))
608
+ if len(samples) > self._eta_window_size:
609
+ del samples[: len(samples) - self._eta_window_size]
610
+
611
+ elapsed_total = max(0.0, now - record["first_time"])
612
+ if force_done or (self.total_progress > 0 and self.current_progress >= self.total_progress):
613
+ self.time_estimate_label.setText(f"已完成,用时: {self._format_time(elapsed_total)}")
614
+ return
615
+
616
+ if self.total_progress <= 0:
617
+ self.time_estimate_label.setText("正在计算预估时间...")
618
+ return
619
+
620
+ # 优先用窗口内(最早样本 -> 当前)的速率;不足则回退到累计平均
621
+ win_time0, win_prog0 = samples[0]
622
+ win_elapsed = now - win_time0
623
+ win_diff = cur - win_prog0
624
+ if win_diff > 0 and win_elapsed > 0:
625
+ time_per_unit = win_elapsed / win_diff
626
+ else:
627
+ cum_elapsed = elapsed_total
628
+ cum_diff = cur - record["first_progress"]
629
+ if cum_diff <= 0 or cum_elapsed <= 0:
630
+ self.time_estimate_label.setText("正在计算预估时间...")
631
+ return
632
+ time_per_unit = cum_elapsed / cum_diff
633
+
634
+ remaining_progress = max(0, self.total_progress - self.current_progress)
635
+ remaining_seconds = time_per_unit * remaining_progress
636
+ self.time_estimate_label.setText(
637
+ f"平均耗时: {self._format_time(time_per_unit)}/单位 | 预计剩余: {self._format_time(remaining_seconds)}"
638
+ )
639
+
640
+ def _format_time(self, seconds: float) -> str:
641
+ seconds = max(0.0, float(seconds))
642
+ if seconds < 1:
643
+ return f"{seconds:.2f} 秒"
644
+ if seconds < 60:
645
+ return f"{seconds:.1f} 秒"
646
+ if seconds < 3600:
647
+ return f"{seconds / 60:.1f} 分钟"
648
+ return f"{seconds / 3600:.1f} 小时"
649
+
650
+ def _append_detail_info(self, info_type: str, message: str, *, log: bool = True) -> ProgressResult:
651
+ try:
652
+ info_type = str(info_type or "信息")
653
+ message = str(message or "")
654
+ config = InfoTypeConfig.get_config(info_type)
655
+ timestamp = datetime.now().strftime("%H:%M:%S")
656
+
657
+ cursor = self.detail_text.textCursor()
658
+ cursor.movePosition(QTextCursor.MoveOperation.End)
659
+ self.detail_text.setTextCursor(cursor)
660
+
661
+ safe_type = escape(info_type)
662
+ safe_message = escape(message).replace("\n", "<br>")
663
+ html_text = (
664
+ f'<div style="margin: 2px 0;">'
665
+ f'<span style="color: #6B7280;">[{timestamp}]</span> '
666
+ f'<span style="color: {config.color}; font-weight: 600;">[{safe_type}]</span> '
667
+ f'<span style="color: {config.color};">{safe_message}</span>'
668
+ f"</div>"
669
+ )
670
+ self.detail_text.insertHtml(html_text)
671
+
672
+ scrollbar = self.detail_text.verticalScrollBar()
673
+ scrollbar.setValue(scrollbar.maximum())
674
+
675
+ if log:
676
+ self._log(info_type, message)
677
+
678
+ self.messageAppended.emit(info_type, message)
679
+ return ProgressResult.success("详细信息已追加")
680
+ except Exception as exc: # pragma: no cover - defensive Qt boundary
681
+ return ProgressResult.failed(f"追加详细信息失败: {exc}")
682
+
683
+ def _set_status(self, status: str, text: str) -> None:
684
+ colors = {
685
+ "idle": "#64748B",
686
+ "running": "#2563EB",
687
+ "paused": "#D97706",
688
+ "success": "#16A34A",
689
+ "warning": "#B45309",
690
+ "error": "#DC2626",
691
+ }
692
+ color = colors.get(status, colors["idle"])
693
+ self.status_value.setText(text)
694
+ self.status_value.setStyleSheet(f"background-color: {color};")
695
+
696
+ def _set_progress_bar_color(self, start_color: str, end_color: str) -> None:
697
+ self.progress_bar.setStyleSheet(
698
+ f"""
699
+ QProgressBar {{
700
+ background-color: #E5E7EB;
701
+ border: 1px solid #CBD5E1;
702
+ border-radius: 5px;
703
+ text-align: center;
704
+ }}
705
+ QProgressBar::chunk {{
706
+ background-color: qlineargradient(
707
+ x1:0, y1:0, x2:1, y2:0,
708
+ stop:0 {start_color},
709
+ stop:1 {end_color}
710
+ );
711
+ border-radius: 4px;
712
+ }}
713
+ """
714
+ )
715
+
716
+ def _current_percent(self) -> float:
717
+ if self.total_progress <= 0:
718
+ return 0.0
719
+ percent = self.current_progress * 100.0 / self.total_progress
720
+ if math.isnan(percent) or math.isinf(percent):
721
+ return 0.0
722
+ return round(max(0.0, min(percent, 100.0)), 2)
723
+
724
+ def _should_accept_project(self, project_id: Optional[str]) -> bool:
725
+ if self.accepted_project_id is None:
726
+ return True
727
+ return str(project_id or "") == str(self.accepted_project_id)
728
+
729
+ def _coerce_int(self, value: Optional[Any], default: int = 0) -> int:
730
+ if value is None:
731
+ return int(default)
732
+ try:
733
+ return int(float(value))
734
+ except (TypeError, ValueError):
735
+ return int(default)
736
+
737
+ def _log(self, info_type: str, message: str) -> None:
738
+ if self.logger is None:
739
+ return
740
+ try:
741
+ self.logger(info_type, message)
742
+ except Exception:
743
+ pass
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: fry-qt6-progress-widget
3
+ Version: 0.1.0
4
+ Summary: A reusable PyQt6 QWidget for displaying task progress, messages, pause state, and time estimates.
5
+ Author: fry
6
+ Project-URL: Homepage, https://gitlab.com/fanrenyi33/fry_python_lib_d150_fry_qt6_progress_widget
7
+ Project-URL: Source, https://gitlab.com/fanrenyi33/fry_python_lib_d150_fry_qt6_progress_widget
8
+ Keywords: pyqt6,qt,progress,widget,task
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: X11 Applications :: Qt
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: User Interfaces
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: PyQt6>=6.5
22
+ Provides-Extra: dev
23
+ Requires-Dist: build>=1.0; extra == "dev"
24
+ Requires-Dist: pytest>=7.0; extra == "dev"
25
+ Requires-Dist: twine>=4.0; extra == "dev"
26
+
27
+ # fry-qt6-progress-widget
28
+
29
+ `fry-qt6-progress-widget` 是一个可复用的 PyQt6 任务进度组件。核心类是 `GeneralTaskProgressWidget`,它继承自 `QWidget`,适合直接放进任意布局;如果你的程序需要 dock,可以使用附带的 `GeneralTaskProgressDock` 薄包装器,或者自己把 widget 放进 `QDockWidget`。
30
+
31
+ ## 安装
32
+
33
+ 发布到 PyPI 后:
34
+
35
+ ```bash
36
+ pip install fry-qt6-progress-widget
37
+ ```
38
+
39
+ 本地开发安装:
40
+
41
+ ```bash
42
+ pip install -e ".[dev]"
43
+ ```
44
+
45
+ ## 最小用法
46
+
47
+ ```python
48
+ import sys
49
+
50
+ from PyQt6.QtWidgets import QApplication
51
+ from fry_qt6_progress_widget import GeneralTaskProgressWidget
52
+
53
+ app = QApplication(sys.argv)
54
+
55
+ widget = GeneralTaskProgressWidget()
56
+ widget.begin_task(project_id="demo", task_name="导入数据", total=100)
57
+ widget.update_progress(current=30, total=100, detail_info="已完成 30 条")
58
+ widget.show()
59
+
60
+ sys.exit(app.exec())
61
+ ```
62
+
63
+ ## 字典信号兼容用法
64
+
65
+ 如果你已有任务系统会发出字典,可以直接调用 `handle_task_progress`:
66
+
67
+ ```python
68
+ widget.handle_task_progress(
69
+ {
70
+ "project_id_str": "demo",
71
+ "task_name_str": "导入数据",
72
+ "now_progress_num": 30,
73
+ "total_num": 100,
74
+ "info_type": "信息",
75
+ "detail_info": "已完成 30 条",
76
+ }
77
+ )
78
+ ```
79
+
80
+ ## Dock 用法
81
+
82
+ ```python
83
+ from PyQt6.QtCore import Qt
84
+ from PyQt6.QtWidgets import QMainWindow
85
+ from fry_qt6_progress_widget import GeneralTaskProgressDock
86
+
87
+ window = QMainWindow()
88
+ dock = GeneralTaskProgressDock(window)
89
+ window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock)
90
+ dock.begin_task(project_id="demo", task_name="后台任务", total=10)
91
+ ```
92
+
93
+ ## 常用 API
94
+
95
+ - `begin_task(project_id="", task_name="任务", total=None)`: 开始任务并重置显示。
96
+ - `update_progress(current=None, total=None, task_name=None, project_id=None, info_type="信息", detail_info="")`: 更新进度和详情消息。
97
+ - `finish_task(project_id=None, message="任务已完成", hide=False)`: 标记任务完成。
98
+ - `set_paused_state(is_paused)`: 切换暂停/恢复状态。
99
+ - `append_message(info_type, message)`: 追加详情日志。
100
+ - `set_project_filter(project_id)`: 只接收指定项目 ID 的进度更新。
101
+ - `get_params_dict()` / `load_params_dict(snapshot)`: 保存和恢复组件状态。
102
+
103
+ ## 示例和文档
104
+
105
+ - [examples/widget_demo.py](examples/widget_demo.py): 直接使用 QWidget。
106
+ - [examples/dock_demo.py](examples/dock_demo.py): 放进 QDockWidget。
107
+ - [docs/core-concepts.md](docs/core-concepts.md): 组件核心关键点。
108
+
109
+ ## 开发与验证
110
+
111
+ ```bash
112
+ pytest
113
+ python -m build
114
+ ```
115
+
116
+ 构建后会生成 `dist/`,发布前建议检查:
117
+
118
+ ```bash
119
+ twine check dist/*
120
+ ```
121
+
122
+ 真正发布到 PyPI 需要维护者账号和 token:
123
+
124
+ ```bash
125
+ twine upload dist/*
126
+ ```
@@ -0,0 +1,8 @@
1
+ fry_qt6_progress_widget/__init__.py,sha256=mJ7TE7vi2PqbjYcfJOBYGZIfHkuqnTSrlGGLPhDtCgg,432
2
+ fry_qt6_progress_widget/dock.py,sha256=YHNVJffy9CuzNeVoCsKnlVAHh0sr5NvehFNt2Qyd8mo,3049
3
+ fry_qt6_progress_widget/progress_widget.py,sha256=6eWxSxb8QFHEPDMdQAdwW2uYMGKXqvUjzdr6psVj6GY,31246
4
+ fry_qt6_progress_widget/py.typed,sha256=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN_XKdLCPjaYaY,2
5
+ fry_qt6_progress_widget-0.1.0.dist-info/METADATA,sha256=OsZKNFMJgxwuEhk2BITow4GiZlxS4aHOwFf3ELdN_r0,4032
6
+ fry_qt6_progress_widget-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ fry_qt6_progress_widget-0.1.0.dist-info/top_level.txt,sha256=lh3Q_guArX17kDSWcZjuWLyUGWK5_FjLxhmHtTDsvO4,24
8
+ fry_qt6_progress_widget-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ fry_qt6_progress_widget