mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 01:47:45 +02:00
418 lines
17 KiB
Python
418 lines
17 KiB
Python
"""
|
|
File Progress List Widget
|
|
|
|
Displays a list of files currently being processed (downloaded, extracted, etc.)
|
|
with individual progress indicators.
|
|
R&D NOTE: This is experimental code for investigation purposes.
|
|
"""
|
|
|
|
from typing import Optional
|
|
import shiboken6
|
|
import time
|
|
|
|
from PySide6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
|
QHBoxLayout, QSizePolicy
|
|
)
|
|
from PySide6.QtCore import Qt, QSize, QTimer, QThread, Signal
|
|
|
|
from jackify.shared.progress_models import FileProgress, OperationType
|
|
|
|
from .summary_progress_widget import SummaryProgressWidget
|
|
from .file_progress_item import FileProgressItem
|
|
|
|
__all__ = ['SummaryProgressWidget', 'FileProgressItem', 'FileProgressList']
|
|
|
|
|
|
class _CpuWorker(QThread):
|
|
"""Background worker for CPU usage sampling — keeps psutil off the main thread."""
|
|
result = Signal(str)
|
|
caches_updated = Signal(object, object, float) # process_cache, child_cache, smoothed_pct
|
|
|
|
def __init__(self, last_pct, process_cache, child_cache):
|
|
super().__init__()
|
|
self._last_pct = last_pct
|
|
self._process_cache = process_cache
|
|
self._child_cache = dict(child_cache) if child_cache else {}
|
|
|
|
def run(self):
|
|
try:
|
|
import psutil, os
|
|
|
|
if self._process_cache is None:
|
|
self._process_cache = psutil.Process(os.getpid())
|
|
# Establish baseline (blocking, but only once and in background)
|
|
self._process_cache.cpu_percent(interval=0.1)
|
|
|
|
num_cpus = psutil.cpu_count() or 1
|
|
total_cpu = self._process_cache.cpu_percent(interval=None) / num_cpus
|
|
|
|
current_child_pids = set()
|
|
try:
|
|
for child in self._process_cache.children(recursive=True):
|
|
try:
|
|
current_child_pids.add(child.pid)
|
|
if child.pid not in self._child_cache:
|
|
# Baseline in background — no longer blocks main thread
|
|
child.cpu_percent(interval=0.1)
|
|
self._child_cache[child.pid] = child
|
|
continue
|
|
total_cpu += self._child_cache[child.pid].cpu_percent(interval=None) / num_cpus
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
pass
|
|
for pid in set(self._child_cache.keys()) - current_child_pids:
|
|
del self._child_cache[pid]
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
pass
|
|
|
|
jackify_names = [
|
|
'jackify-engine', 'texconv', 'texdiag', 'directxtex',
|
|
'texconv_jackify', 'texdiag_jackify', 'directxtex_jackify',
|
|
'7z', '7zz', 'bsarch', 'wine', 'wine64', 'wine64-preloader',
|
|
'steam-run', 'proton',
|
|
]
|
|
tracked_pids = {self._process_cache.pid} | current_child_pids
|
|
try:
|
|
for proc in psutil.process_iter(['name', 'pid', 'cmdline']):
|
|
try:
|
|
if proc.pid in tracked_pids:
|
|
continue
|
|
proc_name = proc.info.get('name', '').lower()
|
|
cmdline_str = ' '.join(proc.info.get('cmdline', []) or []).lower()
|
|
is_jackify = any(n in proc_name for n in jackify_names)
|
|
if not is_jackify and cmdline_str:
|
|
is_jackify = any(n in cmdline_str for n in jackify_names)
|
|
if not is_jackify:
|
|
is_jackify = any(f'{n}.exe' in cmdline_str for n in jackify_names)
|
|
if not is_jackify:
|
|
is_jackify = 'jackify' in cmdline_str and any(
|
|
t in cmdline_str for t in ['engine', 'tools', 'binaries']
|
|
)
|
|
if is_jackify:
|
|
if proc.pid not in self._child_cache:
|
|
proc.cpu_percent(interval=0.1)
|
|
self._child_cache[proc.pid] = proc
|
|
continue
|
|
total_cpu += self._child_cache[proc.pid].cpu_percent(interval=None) / num_cpus
|
|
tracked_pids.add(proc.pid)
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied, AttributeError, TypeError):
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
if self._last_pct > 0:
|
|
total_cpu = self._last_pct * 0.3 + total_cpu * 0.7
|
|
display = min(100.0, total_cpu)
|
|
self.result.emit(f"CPU: {display:.0f}%")
|
|
self.caches_updated.emit(self._process_cache, self._child_cache, total_cpu)
|
|
|
|
except Exception:
|
|
self.result.emit("")
|
|
|
|
|
|
def _debug_log(message):
|
|
from jackify.backend.handlers.config_handler import ConfigHandler
|
|
if ConfigHandler().get('debug_mode', False):
|
|
print(message)
|
|
|
|
|
|
class FileProgressList(QWidget):
|
|
"""
|
|
Widget displaying a list of files currently being processed.
|
|
Shows individual progress for each file.
|
|
"""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self._file_items: dict[str, FileProgressItem] = {}
|
|
self._summary_widget: Optional[SummaryProgressWidget] = None
|
|
self._last_phase: Optional[str] = None
|
|
self._transition_label: Optional[QLabel] = None
|
|
self._last_summary_time: float = 0.0
|
|
self._summary_hold_duration: float = 0.5
|
|
self._last_summary_update: float = 0.0
|
|
self._summary_update_interval: float = 0.1
|
|
|
|
self._setup_ui()
|
|
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
|
|
def _setup_ui(self):
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(2)
|
|
|
|
header_layout = QHBoxLayout()
|
|
header_layout.setContentsMargins(0, 0, 0, 0)
|
|
header_layout.setSpacing(8)
|
|
self.cpu_label = QLabel("")
|
|
self.cpu_label.setStyleSheet("color: #888; font-size: 11px; margin-bottom: 2px;")
|
|
self.cpu_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
|
header_layout.addStretch()
|
|
header_layout.addWidget(self.cpu_label, 0)
|
|
layout.addLayout(header_layout)
|
|
|
|
self.list_widget = QListWidget()
|
|
self.list_widget.setStyleSheet("""
|
|
QListWidget {
|
|
background-color: #222;
|
|
border: 1px solid #444;
|
|
border-radius: 4px;
|
|
}
|
|
QListWidget::item {
|
|
border-bottom: 1px solid #2a2a2a;
|
|
padding: 2px;
|
|
}
|
|
QListWidget::item:selected {
|
|
background-color: #2a2a2a;
|
|
}
|
|
""")
|
|
self.list_widget.setMinimumSize(QSize(300, 20))
|
|
self.list_widget.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
|
self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
layout.addWidget(self.list_widget, stretch=1)
|
|
|
|
self._last_update_time = 0.0
|
|
|
|
# CPU usage tracking — worker thread to avoid blocking the main thread
|
|
self._cpu_timer = QTimer(self)
|
|
self._cpu_timer.timeout.connect(self._start_cpu_worker)
|
|
self._cpu_timer.setInterval(2000)
|
|
self._last_cpu_percent = 0.0
|
|
self._cpu_process_cache = None
|
|
self._child_process_cache = {}
|
|
self._cpu_worker = None
|
|
|
|
def update_files(self, file_progresses: list[FileProgress], current_phase: str = None, summary_info: dict = None):
|
|
current_time = time.time()
|
|
|
|
# Throttle for large file lists
|
|
if len(file_progresses) > 50:
|
|
if current_time - self._last_update_time < 0.1:
|
|
return
|
|
self._last_update_time = current_time
|
|
|
|
# Summary widget path (Installing phase etc.)
|
|
if summary_info and not file_progresses:
|
|
current_step = summary_info.get('current_step', 0)
|
|
max_steps = summary_info.get('max_steps', 0)
|
|
phase_name = current_phase or "Installing files"
|
|
|
|
summary_widget_valid = self._summary_widget and shiboken6.isValid(self._summary_widget)
|
|
if not summary_widget_valid:
|
|
self._summary_widget = None
|
|
|
|
if self._summary_widget:
|
|
if current_time - self._last_summary_update < self._summary_update_interval:
|
|
return
|
|
self._summary_widget.update_progress(current_step, max_steps)
|
|
if self._summary_widget.phase_name != phase_name:
|
|
self._summary_widget.phase_name = phase_name
|
|
self._summary_widget._update_display()
|
|
self._last_summary_update = current_time
|
|
return
|
|
|
|
self._clear_item_widgets()
|
|
self.list_widget.clear()
|
|
self._file_items.clear()
|
|
|
|
self._summary_widget = SummaryProgressWidget(phase_name, current_step, max_steps)
|
|
summary_item = QListWidgetItem()
|
|
summary_item.setSizeHint(self._summary_widget.sizeHint())
|
|
summary_item.setData(Qt.UserRole, "__summary__")
|
|
self.list_widget.addItem(summary_item)
|
|
self.list_widget.setItemWidget(summary_item, self._summary_widget)
|
|
self._last_summary_time = current_time
|
|
self._last_summary_update = current_time
|
|
return
|
|
|
|
# Remove stale summary widget
|
|
if self._summary_widget:
|
|
if current_time - self._last_summary_time >= self._summary_hold_duration:
|
|
self._remove_keyed_item("__summary__")
|
|
self._summary_widget = None
|
|
else:
|
|
return
|
|
|
|
# Remove transition label
|
|
if self._transition_label:
|
|
self._remove_keyed_item("__transition__")
|
|
self._transition_label = None
|
|
|
|
if not file_progresses:
|
|
if current_phase and self._last_phase and current_phase != self._last_phase:
|
|
self._show_transition_message(current_phase)
|
|
else:
|
|
self._clear_item_widgets()
|
|
self.list_widget.clear()
|
|
self._file_items.clear()
|
|
if current_phase:
|
|
self._last_phase = current_phase
|
|
return
|
|
|
|
# Resolve phase label from operations if not provided
|
|
if not current_phase and file_progresses:
|
|
operations = [fp.operation for fp in file_progresses if fp.operation != OperationType.UNKNOWN]
|
|
if operations:
|
|
counts = {}
|
|
for op in operations:
|
|
counts[op] = counts.get(op, 0) + 1
|
|
phase_map = {
|
|
OperationType.DOWNLOAD: "Downloading",
|
|
OperationType.EXTRACT: "Extracting",
|
|
OperationType.VALIDATE: "Validating",
|
|
OperationType.INSTALL: "Installing",
|
|
}
|
|
current_phase = phase_map.get(max(counts, key=counts.get), "")
|
|
|
|
# Build stable key set from incoming data
|
|
current_keys = set()
|
|
for fp in file_progresses:
|
|
current_keys.add(self._stable_key(fp))
|
|
|
|
# Remove items no longer active
|
|
for item_key in list(self._file_items.keys()):
|
|
if item_key not in current_keys:
|
|
for i in range(self.list_widget.count()):
|
|
item = self.list_widget.item(i)
|
|
if item and item.data(Qt.UserRole) == item_key:
|
|
widget = self.list_widget.itemWidget(item)
|
|
if widget:
|
|
self.list_widget.removeItemWidget(item)
|
|
self.list_widget.takeItem(i)
|
|
break
|
|
del self._file_items[item_key]
|
|
|
|
# Update existing or add new items
|
|
for file_progress in file_progresses:
|
|
item_key = self._stable_key(file_progress)
|
|
|
|
if item_key in self._file_items:
|
|
item_widget = self._file_items[item_key]
|
|
if shiboken6.isValid(item_widget):
|
|
try:
|
|
item_widget.update_progress(file_progress)
|
|
continue
|
|
except RuntimeError:
|
|
del self._file_items[item_key]
|
|
else:
|
|
del self._file_items[item_key]
|
|
|
|
item_widget = FileProgressItem(file_progress)
|
|
list_item = QListWidgetItem()
|
|
list_item.setSizeHint(item_widget.sizeHint())
|
|
list_item.setData(Qt.UserRole, item_key)
|
|
self.list_widget.addItem(list_item)
|
|
self.list_widget.setItemWidget(list_item, item_widget)
|
|
self._file_items[item_key] = item_widget
|
|
|
|
if current_phase:
|
|
self._last_phase = current_phase
|
|
|
|
def update_or_add_item(self, item_id: str, label: str, progress: float = 0.0):
|
|
file_progress = FileProgress(
|
|
filename=label,
|
|
operation=OperationType.DOWNLOAD if progress > 0 else OperationType.UNKNOWN,
|
|
percent=progress,
|
|
current_size=0,
|
|
total_size=0,
|
|
)
|
|
self.update_files([file_progress], current_phase=None)
|
|
|
|
def clear_summary(self):
|
|
if self._summary_widget:
|
|
self._remove_keyed_item("__summary__")
|
|
self._summary_widget = None
|
|
|
|
def clear(self):
|
|
self._clear_item_widgets()
|
|
self.list_widget.clear()
|
|
self._file_items.clear()
|
|
self._summary_widget = None
|
|
self._transition_label = None
|
|
self._last_phase = None
|
|
self.stop_cpu_tracking()
|
|
self.cpu_label.setText("")
|
|
|
|
def start_cpu_tracking(self):
|
|
if not self._cpu_timer.isActive():
|
|
self._cpu_timer.start()
|
|
self._start_cpu_worker()
|
|
|
|
def stop_cpu_tracking(self):
|
|
self._cpu_timer.stop()
|
|
if self._cpu_worker and self._cpu_worker.isRunning():
|
|
self._cpu_worker.quit()
|
|
self._cpu_worker.wait(500)
|
|
self._cpu_worker = None
|
|
|
|
def _start_cpu_worker(self):
|
|
# Skip if a worker is already running to avoid pileup
|
|
if self._cpu_worker and self._cpu_worker.isRunning():
|
|
return
|
|
self._cpu_worker = _CpuWorker(self._last_cpu_percent, self._cpu_process_cache, self._child_process_cache)
|
|
self._cpu_worker.result.connect(self._on_cpu_result)
|
|
self._cpu_worker.caches_updated.connect(self._on_cpu_caches)
|
|
self._cpu_worker.start()
|
|
|
|
def _on_cpu_result(self, text: str):
|
|
self.cpu_label.setText(text)
|
|
|
|
def _on_cpu_caches(self, process_cache, child_cache, smoothed_pct):
|
|
self._cpu_process_cache = process_cache
|
|
self._child_process_cache = child_cache
|
|
self._last_cpu_percent = smoothed_pct
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _stable_key(fp: FileProgress) -> str:
|
|
if 'Installing Files:' in fp.filename:
|
|
return "__installing_files__"
|
|
if 'Converting Texture:' in fp.filename:
|
|
return f"__texture_{fp.filename.split('(')[0].strip()}__"
|
|
if fp.filename.startswith('BSA:'):
|
|
return f"__bsa_{fp.filename.split('(')[0].strip()}__"
|
|
if fp.filename.startswith('Wine component:'):
|
|
rest = fp.filename.split(':', 1)[1].strip()
|
|
comp_id = rest.split('|')[0].strip() if '|' in rest else rest
|
|
return f"__wine_comp_{comp_id}__"
|
|
return fp.filename
|
|
|
|
def _clear_item_widgets(self):
|
|
for i in range(self.list_widget.count()):
|
|
item = self.list_widget.item(i)
|
|
if item:
|
|
widget = self.list_widget.itemWidget(item)
|
|
if widget:
|
|
self.list_widget.removeItemWidget(item)
|
|
|
|
def _remove_keyed_item(self, key: str):
|
|
for i in range(self.list_widget.count()):
|
|
item = self.list_widget.item(i)
|
|
if item and item.data(Qt.UserRole) == key:
|
|
widget = self.list_widget.itemWidget(item)
|
|
if widget:
|
|
self.list_widget.removeItemWidget(item)
|
|
self.list_widget.takeItem(i)
|
|
break
|
|
|
|
def _show_transition_message(self, new_phase: str):
|
|
self._clear_item_widgets()
|
|
self.list_widget.clear()
|
|
self._file_items.clear()
|
|
|
|
if self._transition_label is None or not shiboken6.isValid(self._transition_label):
|
|
self._transition_label = QLabel()
|
|
self._transition_label.setAlignment(Qt.AlignCenter)
|
|
self._transition_label.setStyleSheet("color: #888; font-style: italic; padding: 20px;")
|
|
self._transition_label.setText(f"Preparing {new_phase.lower()}...")
|
|
|
|
transition_item = QListWidgetItem()
|
|
transition_item.setSizeHint(self._transition_label.sizeHint())
|
|
transition_item.setData(Qt.UserRole, "__transition__")
|
|
self.list_widget.addItem(transition_item)
|
|
self.list_widget.setItemWidget(transition_item, self._transition_label)
|
|
|