Files
Jackify/jackify/frontends/gui/widgets/file_progress_list.py
2026-02-25 20:54:28 +00:00

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)