From 1e548e280b53bfc8bd2ff4939468396a32cf1f60 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 27 Jan 2026 17:25:28 +0000 Subject: [PATCH 1/2] refactor files list behaviour, added USB icons and handling when the current_dir is deleted --- BlocksScreen/lib/files.py | 736 ++++++++--- BlocksScreen/lib/panels/mainWindow.py | 24 +- BlocksScreen/lib/panels/printTab.py | 8 + BlocksScreen/lib/panels/widgets/filesPage.py | 1140 ++++++++++++----- .../lib/ui/resources/icon_resources.qrc | 1 + .../lib/ui/resources/icon_resources_rc.py | 204 ++- .../ui/resources/media/btn_icons/usb_icon.svg | 13 + BlocksScreen/lib/utils/list_model.py | 28 + 8 files changed, 1665 insertions(+), 489 deletions(-) create mode 100644 BlocksScreen/lib/ui/resources/media/btn_icons/usb_icon.svg diff --git a/BlocksScreen/lib/files.py b/BlocksScreen/lib/files.py index 0eda561d..89a545f2 100644 --- a/BlocksScreen/lib/files.py +++ b/BlocksScreen/lib/files.py @@ -1,180 +1,630 @@ -# -# Gcode File manager -# from __future__ import annotations +import logging import os import typing +from dataclasses import dataclass, field +from enum import Enum, auto import events from events import ReceivedFileData from lib.moonrakerComm import MoonWebSocket from PyQt6 import QtCore, QtGui, QtWidgets +logger = logging.getLogger(__name__) + + +class FileAction(Enum): + """Enumeration of possible file actions from Moonraker notifications.""" + + CREATE_FILE = auto() + DELETE_FILE = auto() + MOVE_FILE = auto() + MODIFY_FILE = auto() + CREATE_DIR = auto() + DELETE_DIR = auto() + MOVE_DIR = auto() + ROOT_UPDATE = auto() + UNKNOWN = auto() + + @classmethod + def from_string(cls, action: str) -> "FileAction": + """Convert Moonraker action string to enum.""" + mapping = { + "create_file": cls.CREATE_FILE, + "delete_file": cls.DELETE_FILE, + "move_file": cls.MOVE_FILE, + "modify_file": cls.MODIFY_FILE, + "create_dir": cls.CREATE_DIR, + "delete_dir": cls.DELETE_DIR, + "move_dir": cls.MOVE_DIR, + "root_update": cls.ROOT_UPDATE, + } + return mapping.get(action.lower(), cls.UNKNOWN) + + +@dataclass +class FileMetadata: + """ + `Data class for file metadata.` + + All data comes from Moonraker API - no local filesystem access. + Thumbnails are stored as ThumbnailInfo objects with paths that can + be fetched via Moonraker's /server/files/gcodes/ endpoint. + """ + + filename: str = "" + thumbnail_images: list[QtGui.QImage] = field(default_factory=list) + filament_total: typing.Union[dict, str, float] = field(default_factory=dict) + estimated_time: int = 0 + layer_count: int = -1 + total_layer: int = -1 + object_height: float = -1.0 + size: int = 0 + modified: float = 0.0 + filament_type: str = "Unknown" + filament_weight_total: float = -1.0 + layer_height: float = -1.0 + first_layer_height: float = -1.0 + first_layer_extruder_temp: float = -1.0 + first_layer_bed_temp: float = -1.0 + chamber_temp: float = -1.0 + filament_name: str = "Unknown" + nozzle_diameter: float = -1.0 + slicer: str = "Unknown" + slicer_version: str = "Unknown" + gcode_start_byte: int = 0 + gcode_end_byte: int = 0 + print_start_time: typing.Optional[float] = None + job_id: typing.Optional[str] = None + + def to_dict(self) -> dict: + """Convert to dictionary for signal emission.""" + return { + "filename": self.filename, + "thumbnail_images": self.thumbnail_images, + "filament_total": self.filament_total, + "estimated_time": self.estimated_time, + "layer_count": self.layer_count, + "total_layer": self.total_layer, + "object_height": self.object_height, + "size": self.size, + "modified": self.modified, + "filament_type": self.filament_type, + "filament_weight_total": self.filament_weight_total, + "layer_height": self.layer_height, + "first_layer_height": self.first_layer_height, + "first_layer_extruder_temp": self.first_layer_extruder_temp, + "first_layer_bed_temp": self.first_layer_bed_temp, + "chamber_temp": self.chamber_temp, + "filament_name": self.filament_name, + "nozzle_diameter": self.nozzle_diameter, + "slicer": self.slicer, + "slicer_version": self.slicer_version, + "gcode_start_byte": self.gcode_start_byte, + "gcode_end_byte": self.gcode_end_byte, + "print_start_time": self.print_start_time, + "job_id": self.job_id, + } + + @classmethod + def from_dict( + cls, data: dict, thumbnail_images: list[QtGui.QImage] + ) -> "FileMetadata": + """ + `Create FileMetadata from Moonraker API response.` + + All data comes directly from Moonraker - no local filesystem access. + """ + filename = data.get("filename", "") + + # Helper to safely get values with fallback + def safe_get(key: str, default: typing.Any) -> typing.Any: + value = data.get(key, default) + if value is None or value == -1.0: + return default + return value + + return cls( + filename=filename, + thumbnail_images=thumbnail_images, + filament_total=safe_get("filament_total", {}), + estimated_time=int(safe_get("estimated_time", 0)), + layer_count=safe_get("layer_count", -1), + total_layer=safe_get("total_layer", -1), + object_height=safe_get("object_height", -1.0), + size=safe_get("size", 0), + modified=safe_get("modified", 0.0), + filament_type=safe_get("filament_type", "Unknown") or "Unknown", + filament_weight_total=safe_get("filament_weight_total", -1.0), + layer_height=safe_get("layer_height", -1.0), + first_layer_height=safe_get("first_layer_height", -1.0), + first_layer_extruder_temp=safe_get("first_layer_extruder_temp", -1.0), + first_layer_bed_temp=safe_get("first_layer_bed_temp", -1.0), + chamber_temp=safe_get("chamber_temp", -1.0), + filament_name=safe_get("filament_name", "Unknown") or "Unknown", + nozzle_diameter=safe_get("nozzle_diameter", -1.0), + slicer=safe_get("slicer", "Unknown") or "Unknown", + slicer_version=safe_get("slicer_version", "Unknown") or "Unknown", + gcode_start_byte=safe_get("gcode_start_byte", 0), + gcode_end_byte=safe_get("gcode_end_byte", 0), + print_start_time=data.get("print_start_time"), + job_id=data.get("job_id"), + ) + class Files(QtCore.QObject): - request_file_list = QtCore.pyqtSignal([], [str], name="api-get-files-list") + """ + `Manages gcode files with event-driven updates.` + + Architecture: + 1. On WebSocket connection: requests full file list once via initial_load() + 2. On notify_filelist_changed: updates internal state incrementally + 3. Emits signals for UI components to react to changes + + Signals emitted: + - on_dirs: Full directory list (for initial load) + - on_file_list: Full file list (for initial load) + - fileinfo: Single file metadata (when metadata is received) + - file_added: Single file was added + - file_removed: Single file was removed + - file_modified: Single file was modified + - dir_added: Single directory was added + - dir_removed: Single directory was removed + - full_refresh_needed: Root changed, need complete refresh + """ + + # Signals for API requests (to Moonraker) + request_file_list = QtCore.pyqtSignal([], [str], name="api_get_files_list") request_dir_info = QtCore.pyqtSignal( - [], [str], [str, bool], name="api-get-dir-info" - ) - request_file_metadata = QtCore.pyqtSignal([str], name="get_file_metadata") - request_files_thumbnails = QtCore.pyqtSignal([str], name="request_files_thumbnail") - request_file_download = QtCore.pyqtSignal([str, str], name="file_download") - on_dirs: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( - list, name="on-dirs" - ) - on_file_list: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( - list, name="on_file_list" - ) - fileinfo: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( - dict, name="fileinfo" + [], [str], [str, bool], name="api_get_dir_info" ) + request_file_metadata = QtCore.pyqtSignal(str, name="get_file_metadata") + request_thumbnail = QtCore.pyqtSignal(str, name="request_thumbnail") + request_file_download = QtCore.pyqtSignal(str, str, name="file_download") - def __init__( - self, - parent: QtCore.QObject, - ws: MoonWebSocket, - update_interval: int = 5000, - ) -> None: - super(Files, self).__init__(parent) + # Signals for UI updates (full refresh) + on_dirs = QtCore.pyqtSignal(list, name="on_dirs") + on_file_list = QtCore.pyqtSignal(list, name="on_file_list") + fileinfo = QtCore.pyqtSignal(dict, name="fileinfo") + + # Signals for incremental updates (event-driven) + file_added = QtCore.pyqtSignal(dict, name="file_added") + file_removed = QtCore.pyqtSignal(str, name="file_removed") + file_modified = QtCore.pyqtSignal(dict, name="file_modified") + dir_added = QtCore.pyqtSignal(dict, name="dir_added") + dir_removed = QtCore.pyqtSignal(str, name="dir_removed") + full_refresh_needed = QtCore.pyqtSignal(name="full_refresh_needed") + + # Constants + GCODE_EXTENSION = ".gcode" + GCODE_PATH = "~/printer_data/gcodes" + + def __init__(self, parent: QtCore.QObject, ws: MoonWebSocket) -> None: + super().__init__(parent) self.ws = ws + + # Internal state - use instance variables, not class variables! + self._files: dict[str, dict] = {} # filename -> file data + self._directories: dict[str, dict] = {} # dirname -> dir data + self._files_metadata: dict[str, FileMetadata] = {} # filename -> metadata + self._current_directory: str = "" + self._initial_load_complete: bool = False + self.gcode_path = os.path.expanduser("~/printer_data/gcodes") - self.files: list = [] - self.directories: list = [] - self.files_metadata: dict = {} - self.request_file_list.connect(slot=self.ws.api.get_file_list) - self.request_file_list[str].connect(slot=self.ws.api.get_file_list) - self.request_dir_info.connect(slot=self.ws.api.get_dir_information) - self.request_dir_info[str, bool].connect(self.ws.api.get_dir_information) - self.request_dir_info[str].connect(slot=self.ws.api.get_dir_information) - self.request_file_metadata.connect(slot=self.ws.api.get_gcode_metadata) - self.request_files_thumbnails.connect(slot=self.ws.api.get_gcode_thumbnail) - self.request_file_download.connect(slot=self.ws.api.download_file) + + self._connect_signals() QtWidgets.QApplication.instance().installEventFilter(self) # type: ignore + def _connect_signals(self) -> None: + """Connect internal signals to websocket API.""" + self.request_file_list.connect(self.ws.api.get_file_list) + self.request_file_list[str].connect(self.ws.api.get_file_list) + self.request_dir_info.connect(self.ws.api.get_dir_information) + self.request_dir_info[str, bool].connect(self.ws.api.get_dir_information) + self.request_dir_info[str].connect(self.ws.api.get_dir_information) + self.request_file_metadata.connect(self.ws.api.get_gcode_metadata) + self.request_thumbnail.connect(self.ws.api.get_gcode_thumbnail) + self.request_file_download.connect(self.ws.api.download_file) + + @property + def file_list(self) -> list[dict]: + """Get list of files in current directory.""" + return list(self._files.values()) + @property - def file_list(self): - """Available files list""" - return self.files + def directories(self) -> list[dict]: + """Get list of directories in current directory.""" + return list(self._directories.values()) - def handle_message_received(self, method: str, data, params: dict) -> None: - """Handle file related messages received by moonraker""" + @property + def current_directory(self) -> str: + """Get current directory path.""" + return self._current_directory + + @current_directory.setter + def current_directory(self, value: str) -> None: + """Set current directory path.""" + self._current_directory = value + + @property + def is_loaded(self) -> bool: + """Check if initial load is complete.""" + return self._initial_load_complete + + def get_file_metadata(self, filename: str) -> typing.Optional[FileMetadata]: + """Get cached metadata for a file.""" + return self._files_metadata.get(filename) + + def get_file_data(self, filename: str) -> dict: + """Get cached file data dict for a file.""" + clean_name = filename.removeprefix("/") + metadata = self._files_metadata.get(clean_name) + if metadata: + return metadata.to_dict() + return {} + + def refresh_directory(self, directory: str = "") -> None: + """ + Force refresh of a specific directory. + Use sparingly - prefer event-driven updates. + """ + self._current_directory = directory + self.request_dir_info[str, bool].emit(directory, True) + + def initial_load(self) -> None: + """Perform initial load of file list. Call once on connection.""" + logger.info("Performing initial file list load") + self._initial_load_complete = False + self.request_dir_info[str, bool].emit("", True) + + def handle_filelist_changed(self, data: typing.Union[dict, list]) -> None: + """ + Handle notify_filelist_changed from Moonraker. + + This is the main entry point for event-driven updates. + Called from your websocket message handler when receiving + 'notify_filelist_changed' notifications. + + Args: + data: The notification data from Moonraker (various formats supported). + """ + # Handle nested "params" key (full JSON-RPC envelope) + if isinstance(data, dict) and "params" in data: + data = data.get("params", []) + + # Handle list format + if isinstance(data, list): + if len(data) > 0: + data = data[0] + else: + logger.warning("Received empty list in filelist_changed") + return + + # Validate we have a dict + if not isinstance(data, dict): + logger.warning( + "Unexpected data type in filelist_changed: %s", type(data).__name__ + ) + return + + action_str = data.get("action", "") + action = FileAction.from_string(action_str) + item = data.get("item", {}) + source_item = data.get("source_item", {}) + + logger.debug("File list changed: action=%s, item=%s", action_str, item) + + handlers = { + FileAction.CREATE_FILE: self._handle_file_created, + FileAction.DELETE_FILE: self._handle_file_deleted, + FileAction.MODIFY_FILE: self._handle_file_modified, + FileAction.MOVE_FILE: self._handle_file_moved, + FileAction.CREATE_DIR: self._handle_dir_created, + FileAction.DELETE_DIR: self._handle_dir_deleted, + FileAction.MOVE_DIR: self._handle_dir_moved, + FileAction.ROOT_UPDATE: self._handle_root_update, + } + + handler = handlers.get(action) + if handler: + handler(item, source_item) + else: + logger.warning("Unknown file action: %s", action_str) + + def _handle_file_created(self, item: dict, _: dict) -> None: + """Handle new file creation.""" + path = item.get("path", "") + if not path: + return + + # Check if this is actually a USB mount (Moonraker reports USB as files) + # USB mounts: path like "USB-sda1" with no extension, at root level + if self._is_usb_mount(path): + # Treat as directory instead + item["dirname"] = path + self._handle_dir_created(item, {}) + return + + # Only process gcode files + if not path.lower().endswith(self.GCODE_EXTENSION): + return + + # Add to internal state + self._files[path] = item + + # Emit signal for UI and request metadata + self.file_added.emit(item) + self.request_file_metadata.emit(path.removeprefix("/")) + + logger.info("File created: %s", path) + + def _handle_file_deleted(self, item: dict, _: dict) -> None: + """Handle file deletion.""" + path = item.get("path", "") + if not path: + return + + # Check if this is actually a USB mount being removed + if self._is_usb_mount(path): + item["dirname"] = path + self._handle_dir_deleted(item, {}) + return + + # Remove from internal state + self._files.pop(path, None) + self._files_metadata.pop(path.removeprefix("/"), None) + + # Emit signal for UI + self.file_removed.emit(path) + logger.info("File deleted: %s", path) + + def _handle_file_modified(self, item: dict, _: dict) -> None: + """Handle file modification.""" + path = item.get("path", "") + if not path or not path.lower().endswith(self.GCODE_EXTENSION): + return + + # Update internal state + self._files[path] = item + + # Clear cached metadata and request fresh + self._files_metadata.pop(path.removeprefix("/"), None) + + # Emit signal and request new metadata + self.request_file_metadata.emit(path.removeprefix("/")) + self.file_modified.emit(item) + logger.info("File modified: %s", path) + + def _handle_file_moved(self, item: dict, source_item: dict) -> None: + """Handle file move/rename.""" + old_path = source_item.get("path", "") + new_path = item.get("path", "") + + # Remove from old location + if old_path: + self._handle_file_deleted(source_item, {}) + + # Add to new location + if new_path: + self._handle_file_created(item, {}) + + logger.info("File moved: %s -> %s", old_path, new_path) + + def _handle_dir_created(self, item: dict, _: dict) -> None: + """Handle directory creation.""" + path = item.get("path", "") + dirname = item.get("dirname", "") + + # Extract dirname from path if not provided + if not dirname and path: + dirname = path.rstrip("/").split("/")[-1] + + if not dirname or dirname.startswith("."): + return + + # Ensure dirname is in item for UI + item["dirname"] = dirname + + # Add to internal state + self._directories[dirname] = item + + # Emit signal for UI + self.dir_added.emit(item) + logger.info("Directory created: %s", dirname) + + def _handle_dir_deleted(self, item: dict, _: dict) -> None: + """Handle directory deletion.""" + path = item.get("path", "") + dirname = item.get("dirname", "") + + # Extract dirname from path if not provided + if not dirname and path: + dirname = path.rstrip("/").split("/")[-1] + + if not dirname: + return + + # Remove from internal state + self._directories.pop(dirname, None) + + # Emit signal for UI + self.dir_removed.emit(dirname) + logger.info("Directory deleted: %s", dirname) + + def _handle_dir_moved(self, item: dict, source_item: dict) -> None: + """Handle directory move/rename.""" + self._handle_dir_deleted(source_item, {}) + self._handle_dir_created(item, {}) + + def _handle_root_update(self, _: dict, __: dict) -> None: + """Handle root update - requires full refresh.""" + logger.info("Root update detected, requesting full refresh") + self.full_refresh_needed.emit() + self.initial_load() + + @staticmethod + def _is_usb_mount(path: str) -> bool: + """ + Check if a path is a USB mount point. + + Moonraker incorrectly reports USB mounts as files with create_file/delete_file. + USB mounts have paths like "USB-sda1" - starting with "USB-" and at root level. + + Args: + path: The file path to check + + Returns: + True if this appears to be a USB mount point + """ + path = path.removeprefix("/") + # USB mounts are at root level (no slashes) and start with "USB-" + return "/" not in path and path.startswith("USB-") + + def handle_message_received( + self, method: str, data: typing.Any, params: dict + ) -> None: + """Handle file-related messages received from Moonraker.""" if "server.files.list" in method: - self.files.clear() - self.files = data - [self.request_file_metadata.emit(item["path"]) for item in self.files] + self._process_file_list(data) elif "server.files.metadata" in method: - if data["filename"] in self.files_metadata.keys(): - if not data.get("filename", None): - return - self.files_metadata.update({data["filename"]: data}) - else: - self.files_metadata[data["filename"]] = data + self._process_metadata(data) elif "server.files.get_directory" in method: - self.directories = data.get("dirs", {}) - self.files.clear() - self.files = data.get("files", []) - self.on_file_list[list].emit(self.files) - self.on_dirs[list].emit(self.directories) + self._process_directory_info(data) + + def _process_file_list(self, data: list) -> None: + """Process full file list response.""" + self._files.clear() + + for item in data: + path = item.get("path", item.get("filename", "")) + if path: + self._files[path] = item + # Request metadata for each file + self.request_file_metadata.emit(path.removeprefix("/")) + + self._initial_load_complete = True + self.on_file_list.emit(self.file_list) + logger.info("Loaded %d files", len(self._files)) + + def _process_metadata(self, data: dict) -> None: + """Process file metadata response from Moonraker.""" + filename = data.get("filename") + if not filename: + return + + # Create metadata from Moonraker response (no local filesystem access) + thumbnails = data.get("thumbnails", []) + base_dir = os.path.dirname(os.path.join(self.gcode_path, filename)) + thumbnail_paths = [ + os.path.join(base_dir, t.get("relative_path", "")) + for t in thumbnails + if isinstance(t.get("relative_path", None), str) and t["relative_path"] + ] + + # Load images, filtering out invalid files + thumbnail_images = [] + for path in thumbnail_paths: + image = QtGui.QImage(path) + if not image.isNull(): # skip loading errors + thumbnail_images.append(image) + + metadata = FileMetadata.from_dict(data, thumbnail_images) + self._files_metadata[filename] = metadata + self.fileinfo.emit(metadata.to_dict()) + + def _process_directory_info(self, data: dict) -> None: + """Process directory info response.""" + self._directories.clear() + self._files.clear() + + # Process directories + for dir_data in data.get("dirs", []): + dirname = dir_data.get("dirname", "") + if dirname and not dirname.startswith("."): + self._directories[dirname] = dir_data + + # Process files + for file_data in data.get("files", []): + filename = file_data.get("filename", file_data.get("path", "")) + if filename: + self._files[filename] = file_data + + # Emit signals for UI + self.on_file_list.emit(self.file_list) + self.on_dirs.emit(self.directories) + self._initial_load_complete = True + + logger.info( + "Directory loaded: %d dirs, %d files", + len(self._directories), + len(self._files), + ) @QtCore.pyqtSlot(str, str, name="on_request_delete_file") def on_request_delete_file(self, filename: str, directory: str = "gcodes") -> None: - """Requests deletion of a file + """Request deletion of a file.""" + if not filename: + logger.warning("Attempted to delete file with empty filename") + return - Args: - filename (str): file to delete - directory (str): root directory where the file is located - """ - if not directory: + if directory: + self.ws.api.delete_file(filename, directory) + else: self.ws.api.delete_file(filename) - return - self.ws.api.delete_file(filename, directory) # Use the root directory 'gcodes' + + logger.info("Requested deletion of: %s", filename) @QtCore.pyqtSlot(str, name="on_request_fileinfo") def on_request_fileinfo(self, filename: str) -> None: - """Requests metadata for a file + """Request and emit metadata for a file.""" + clean_filename = filename.removeprefix("/") + cached = self._files_metadata.get(clean_filename) - Args: - filename (str): file to get metadata from - """ - _data: dict = { - "thumbnail_images": list, - "filament_total": dict, - "estimated_time": int, - "layer_count": int, - "object_height": float, - "size": int, - "filament_type": str, - "filament_weight_total": float, - "layer_height": float, - "first_layer_height": float, - "first_layer_extruder_temp": float, - "first_layer_bed_temp": float, - "chamber_temp": float, - "filament_name": str, - "nozzle_diameter": float, - "slicer": str, - "filename": str, - } - _file_metadata = self.files_metadata.get(str(filename), {}) - _data.update({"filename": filename}) - _thumbnails = _file_metadata.get("thumbnails", {}) - _thumbnail_paths = list( - map( - lambda thumbnail_path: os.path.join( - os.path.dirname(os.path.join(self.gcode_path, filename)), - thumbnail_path.get("relative_path", "?"), - ), - _thumbnails, - ) - ) - _thumbnail_images = list(map(lambda path: QtGui.QImage(path), _thumbnail_paths)) - _data.update({"thumbnail_images": _thumbnail_images}) - _data.update({"filament_total": _file_metadata.get("filament_total", "?")}) - _data.update({"estimated_time": _file_metadata.get("estimated_time", 0)}) - _data.update({"layer_count": _file_metadata.get("layer_count", -1.0)}) - _data.update({"total_layer": _file_metadata.get("total_layer", -1.0)}) - _data.update({"object_height": _file_metadata.get("object_height", -1.0)}) - _data.update({"nozzle_diameter": _file_metadata.get("nozzle_diameter", -1.0)}) - _data.update({"layer_height": _file_metadata.get("layer_height", -1.0)}) - _data.update( - {"first_layer_height": _file_metadata.get("first_layer_height", -1.0)} - ) - _data.update( - { - "first_layer_extruder_temp": _file_metadata.get( - "first_layer_extruder_temp", -1.0 - ) - } - ) - _data.update( - {"first_layer_bed_temp": _file_metadata.get("first_layer_bed_temp", -1.0)} - ) - _data.update({"chamber_temp": _file_metadata.get("chamber_temp", -1.0)}) - _data.update({"filament_name": _file_metadata.get("filament_name", -1.0)}) - _data.update({"filament_type": _file_metadata.get("filament_type", -1.0)}) - _data.update( - {"filament_weight_total": _file_metadata.get("filament_weight_total", -1.0)} - ) - _data.update({"slicer": _file_metadata.get("slicer", -1.0)}) - self.fileinfo.emit(_data) - - def eventFilter(self, a0: QtCore.QObject, a1: QtCore.QEvent) -> bool: - """Handle Websocket and Klippy events""" - if a1.type() == events.WebSocketOpen.type(): - self.request_file_list.emit() - self.request_dir_info[str, bool].emit("", False) + if cached: + self.fileinfo.emit(cached.to_dict()) + else: + self.request_file_metadata.emit(clean_filename) + + @QtCore.pyqtSlot(name="get_dir_info") + @QtCore.pyqtSlot(str, name="get_dir_info") + @QtCore.pyqtSlot(str, bool, name="get_dir_info") + def get_dir_information( + self, directory: str = "", extended: bool = True + ) -> typing.Optional[list]: + """Get directory information - from cache or request from Moonraker.""" + self._current_directory = directory + + if not extended and self._initial_load_complete: + # Return cached data if available and extended info not needed + return self.directories + + return self.ws.api.get_dir_information(directory, extended) + + def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool: + """Handle application-level events.""" + if event.type() == events.WebSocketOpen.type(): + self.initial_load() return False - if a1.type() == events.KlippyDisconnected.type(): - self.files_metadata.clear() - self.files.clear() + + if event.type() == events.KlippyDisconnected.type(): + self._clear_all_data() return False - return super().eventFilter(a0, a1) - def event(self, a0: QtCore.QEvent) -> bool: - """Filter ReceivedFileData event""" - if a0.type() == ReceivedFileData.type(): - if isinstance(a0, ReceivedFileData): - self.handle_message_received(a0.method, a0.data, a0.params) + return super().eventFilter(obj, event) + + def event(self, event: QtCore.QEvent) -> bool: + """Handle object-level events.""" + if event.type() == ReceivedFileData.type(): + if isinstance(event, ReceivedFileData): + self.handle_message_received(event.method, event.data, event.params) return True - return super().event(a0) + return super().event(event) + + def _clear_all_data(self) -> None: + """Clear all cached data.""" + self._files.clear() + self._directories.clear() + self._files_metadata.clear() + self._initial_load_complete = False + logger.info("All file data cleared") diff --git a/BlocksScreen/lib/panels/mainWindow.py b/BlocksScreen/lib/panels/mainWindow.py index 32355803..3d3d9f94 100644 --- a/BlocksScreen/lib/panels/mainWindow.py +++ b/BlocksScreen/lib/panels/mainWindow.py @@ -12,13 +12,13 @@ from lib.panels.networkWindow import NetworkControlWindow from lib.panels.printTab import PrintTab from lib.panels.utilitiesTab import UtilitiesTab +from lib.panels.widgets.basePopup import BasePopup from lib.panels.widgets.connectionPage import ConnectionPage +from lib.panels.widgets.loadWidget import LoadingOverlayWidget from lib.panels.widgets.popupDialogWidget import Popup +from lib.panels.widgets.updatePage import UpdatePage from lib.printer import Printer from lib.ui.mainWindow_ui import Ui_MainWindow # With header -from lib.panels.widgets.updatePage import UpdatePage -from lib.panels.widgets.basePopup import BasePopup -from lib.panels.widgets.loadWidget import LoadingOverlayWidget # from lib.ui.mainWindow_v2_ui import Ui_MainWindow # No header from lib.ui.resources.background_resources_rc import * @@ -578,7 +578,8 @@ def _handle_notify_klippy_message(self, method, data, metadata) -> None: @api_handler def _handle_notify_filelist_changed_message(self, method, data, metadata) -> None: """Handle websocket file list messages""" - ... + print(data) + self.file_data.handle_filelist_changed(data) @api_handler def _handle_notify_service_state_changed_message( @@ -619,9 +620,6 @@ def _handle_notify_gcode_response_message(self, method, data, metadata) -> None: def _handle_error_message(self, method, data, metadata) -> None: """Handle error messages""" self.handle_error_response[list].emit([data, metadata]) - if "metadata" in data.get("message", "").lower(): - # Quick fix, don't care about no metadata errors - return if self._popup_toggle: return text = data @@ -630,11 +628,23 @@ def _handle_error_message(self, method, data, metadata) -> None: text = f"{data['message']}" else: text = data + lower_text = text.lower() + if "metadata" in lower_text: + start = text.find("<") + 1 + end = text.find(">", start) + path = text[start:end] if start > 0 and end > start else "" + self.printPanel.filesPage_widget.request_metadata(path) + return + elif "does not exist" in lower_text: + if "file" in lower_text: + return + self.printPanel.filesPage_widget.back_btn.click() self.popup.new_message( message_type=Popup.MessageType.ERROR, message=str(text), userInput=True, ) + _logger.error(text) @api_handler def _handle_notify_cpu_throttled_message(self, method, data, metadata) -> None: diff --git a/BlocksScreen/lib/panels/printTab.py b/BlocksScreen/lib/panels/printTab.py index 7431938e..bb0ea9a2 100644 --- a/BlocksScreen/lib/panels/printTab.py +++ b/BlocksScreen/lib/panels/printTab.py @@ -129,6 +129,14 @@ def __init__( ) self.filesPage_widget.request_dir_info.connect(self.file_data.request_dir_info) self.file_data.on_file_list.connect(self.filesPage_widget.on_file_list) + self.file_data.file_added.connect(self.filesPage_widget.on_file_added) + self.file_data.file_removed.connect(self.filesPage_widget.on_file_removed) + self.file_data.file_modified.connect(self.filesPage_widget.on_file_modified) + self.file_data.dir_added.connect(self.filesPage_widget.on_dir_added) + self.file_data.dir_removed.connect(self.filesPage_widget.on_dir_removed) + self.file_data.full_refresh_needed.connect( + self.filesPage_widget.on_full_refresh_needed + ) self.jobStatusPage_widget = JobStatusWidget(self) self.addWidget(self.jobStatusPage_widget) self.confirmPage_widget.on_accept.connect( diff --git a/BlocksScreen/lib/panels/widgets/filesPage.py b/BlocksScreen/lib/panels/widgets/filesPage.py index f8fa490f..ecc4b36a 100644 --- a/BlocksScreen/lib/panels/widgets/filesPage.py +++ b/BlocksScreen/lib/panels/widgets/filesPage.py @@ -1,411 +1,975 @@ import logging -import os import typing import helper_methods from lib.utils.blocks_Scrollbar import CustomScrollBar from lib.utils.icon_button import IconButton -from PyQt6 import QtCore, QtGui, QtWidgets - from lib.utils.list_model import EntryDelegate, EntryListModel, ListItem +from PyQt6 import QtCore, QtGui, QtWidgets logger = logging.getLogger("logs/BlocksScreen.log") class FilesPage(QtWidgets.QWidget): - request_back: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( - name="request-back" - ) - file_selected: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( - str, dict, name="file-selected" - ) - request_file_info: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( - str, name="request-file-info" - ) - request_dir_info: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( - [], [str], [str, bool], name="api-get-dir-info" - ) - request_file_list: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( - [], [str], name="api-get-files-list" + """ + Widget for displaying and navigating gcode files. + + This widget displays a list of gcode files and directories, + allowing navigation and file selection. It receives updates + from the Files manager via signals. + + Signals emitted: + - request_back: User wants to go back (header button) + - file_selected(str, dict): User selected a file + - request_file_info(str): Request metadata for a file + - request_dir_info(str): Request directory contents + - request_file_metadata(str): Request gcode metadata + """ + + # Signals + request_back = QtCore.pyqtSignal(name="request_back") + file_selected = QtCore.pyqtSignal(str, dict, name="file_selected") + request_file_info = QtCore.pyqtSignal(str, name="request_file_info") + request_dir_info = QtCore.pyqtSignal( + [], [str], [str, bool], name="api_get_dir_info" ) - request_file_metadata: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( - str, name="api-get-gcode-metadata" - ) - file_list: list = [] - files_data: dict = {} - directories: list = [] - - def __init__(self, parent) -> None: - super().__init__() - self.model = EntryListModel() - self.entry_delegate = EntryDelegate() - self._setupUI() + request_file_list = QtCore.pyqtSignal([], [str], name="api_get_files_list") + request_file_metadata = QtCore.pyqtSignal(str, name="api_get_gcode_metadata") + + # Constants + GCODE_EXTENSION = ".gcode" + USB_PREFIX = "USB-" + ITEM_HEIGHT = 80 + LEFT_FONT_SIZE = 17 + RIGHT_FONT_SIZE = 12 + + # Icon paths - centralized for easy modification + ICON_PATHS = { + "back_folder": ":/ui/media/btn_icons/back_folder.svg", + "folder": ":/ui/media/btn_icons/folderIcon.svg", + "right_arrow": ":/arrow_icons/media/btn_icons/right_arrow.svg", + "usb": ":/ui/media/btn_icons/usb_icon.svg", + "back": ":/ui/media/btn_icons/back.svg", + "refresh": ":/ui/media/btn_icons/refresh.svg", + } + + def __init__(self, parent: typing.Optional[QtWidgets.QWidget] = None) -> None: + super().__init__(parent) + + # Instance data - NOT class-level to avoid sharing between instances + self._file_list: list[dict] = [] + self._files_data: dict[str, dict] = {} # filename -> metadata dict + self._directories: list[dict] = [] + self._curr_dir: str = "" + self._pending_action: bool = False + self._pending_metadata_requests: set[str] = set() # Track pending requests + self._metadata_retry_count: dict[str, int] = {} # Track retry count per file + self._icons: dict[str, QtGui.QPixmap] = {} + + # Model and delegate + self._model = EntryListModel() + self._entry_delegate = EntryDelegate() + + # Setup UI + self._setup_ui() + self._load_icons() + self._connect_signals() + + # Widget attributes self.setMouseTracking(True) self.setAttribute(QtCore.Qt.WidgetAttribute.WA_AcceptTouchEvents, True) - self.curr_dir: str = "" - self.ReloadButton.clicked.connect( - lambda: self.request_dir_info[str].emit(self.curr_dir) - ) - self.listWidget.verticalScrollBar().valueChanged.connect(self._handle_scrollbar) - self.scrollbar.valueChanged.connect(self._handle_scrollbar) - self.scrollbar.valueChanged.connect( - lambda value: self.listWidget.verticalScrollBar().setValue(value) - ) - self.back_btn.clicked.connect(self.reset_dir) - self.entry_delegate.item_selected.connect(self._on_item_selected) - self._refresh_one_and_half_sec_timer = QtCore.QTimer() - self._refresh_one_and_half_sec_timer.timeout.connect( - lambda: self.request_dir_info[str].emit(self.curr_dir) - ) - self._refresh_one_and_half_sec_timer.start(1500) + @property + def current_directory(self) -> str: + """Get current directory path.""" + return self._curr_dir + + @current_directory.setter + def current_directory(self, value: str) -> None: + """Set current directory path.""" + self._curr_dir = value + + def reload_gcodes_folder(self) -> None: + """Request reload of the gcodes folder from root.""" + self.request_dir_info[str].emit("") + + def clear_files_data(self) -> None: + """Clear all cached file data.""" + self._files_data.clear() + self._pending_metadata_requests.clear() + self._metadata_retry_count.clear() + + def request_metadata(self, file_path: str) -> bool: + """ + Request metadata with a maximum of 3 retries per file. + + Used by error handlers to retry metadata requests that failed. - @QtCore.pyqtSlot(ListItem, name="on-item-selected") - def _on_item_selected(self, item: ListItem) -> None: - """Slot called when a list item is selected in the UI. - This method is connected to the `item_selected` signal of the entry delegate. - It handles the selection of a `ListItem` and process it accoding it with its type Args: - item : ListItem The item that was selected by the user. + file_path: Path to the file + + Returns: + True if request was made (under retry limit), False if limit reached """ - if not item.left_icon: - filename = self.curr_dir + "/" + item.text + ".gcode" - self._fileItemClicked(filename) - else: - if item.text == "Go Back": - go_back_path = os.path.dirname(self.curr_dir) - if go_back_path == "/": - go_back_path = "" - self._on_goback_dir(go_back_path) - else: - self._dirItemClicked("/" + item.text) + clean_path = file_path.removeprefix("/") - @QtCore.pyqtSlot(name="reset-dir") - def reset_dir(self) -> None: - """Reset current directory""" - self.curr_dir = "" - self.request_dir_info[str].emit(self.curr_dir) + current_count = self._metadata_retry_count.get(clean_path, 0) + if current_count < 3: + self._metadata_retry_count[clean_path] = current_count + 1 + self.request_file_metadata.emit(clean_path) + return True - def showEvent(self, a0: QtGui.QShowEvent) -> None: - """Re-implemented method, handle widget show event""" - self._build_file_list() - return super().showEvent(a0) + logger.warning("Metadata retry limit reached for: %s", clean_path) + return False - @QtCore.pyqtSlot(list, name="on-file-list") + def reset_metadata_retry(self, file_path: str) -> None: + """Reset the retry counter for a specific file.""" + clean_path = file_path.removeprefix("/") + self._metadata_retry_count.pop(clean_path, None) + + @QtCore.pyqtSlot(list, name="on_file_list") def on_file_list(self, file_list: list) -> None: - """Handle receiving files list from websocket""" - self.files_data.clear() - self.file_list = file_list + """Handle receiving full files list.""" + self._file_list = file_list.copy() if file_list else [] - @QtCore.pyqtSlot(list, name="on-dirs") + @QtCore.pyqtSlot(list, name="on_dirs") def on_directories(self, directories_data: list) -> None: - """Handle receiving available directories from websocket""" - self.directories = directories_data + """Handle receiving full directories list.""" + self._directories = directories_data.copy() if directories_data else [] + if self.isVisible(): self._build_file_list() - @QtCore.pyqtSlot(dict, name="on-fileinfo") + @QtCore.pyqtSlot(dict, name="on_fileinfo") def on_fileinfo(self, filedata: dict) -> None: - """Method called per file to contruct file entry to the list""" + """ + Handle receiving file metadata. + + This is called both during initial load and when new files are added. + Creates/updates the file entry in the list. + Inserts files in sorted position (by modification time, newest first). + """ if not filedata or not self.isVisible(): return + filename = filedata.get("filename", "") if not filename: return - self.files_data.update({f"{filename}": filedata}) - estimated_time = filedata.get("estimated_time", 0) - seconds = int(estimated_time) if isinstance(estimated_time, (int, float)) else 0 - filament_type = ( - filedata.get("filament_type", "Unknown filament") - if filedata.get("filament_type", "Unknown filament") != -1.0 - else "Unknown filament" - ) - time_str = "" - days, hours, minutes, _ = helper_methods.estimate_print_time(seconds) - if seconds <= 0: - time_str = "??" - elif seconds < 60: - time_str = "less than 1 minute" - else: - if days > 0: - time_str = f"{days}d {hours}h {minutes}m" - elif hours > 0: - time_str = f"{hours}h {minutes}m" - else: - time_str = f"{minutes}m" - name = helper_methods.get_file_name(filename) + # Cache the file data + self._files_data[filename] = filedata + + # Remove from pending requests + self._pending_metadata_requests.discard(filename) + + # Check if this file should be displayed in current view + file_dir = self._get_parent_directory(filename) + current = self._curr_dir.removeprefix("/") + + # Both empty = root directory, otherwise must match exactly + if file_dir != current: + return + + # Check if item already exists in model + display_name = self._get_display_name(filename) + if self._model_contains_item(display_name): + # Item exists, update it by removing and re-adding + self._model.remove_item_by_text(display_name) + + # Create the list item + item = self._create_file_list_item(filedata) + if item: + # Find correct position (sorted by modification time, newest first) + insert_position = self._find_file_insert_position( + filedata.get("modified", 0) + ) + self._model.insert_item(insert_position, item) + self._setup_scrollbar() + self._hide_placeholder() + + def _find_file_insert_position(self, modified_time: float) -> int: + """ + Find the correct position to insert a new file. + + Files should be: + 1. After all directories + 2. Sorted by modification time (newest first) + + Returns: + The index at which to insert the new file. + """ + insert_pos = 0 + + for i in range(self._model.rowCount()): + index = self._model.index(i) + item = self._model.data(index, QtCore.Qt.ItemDataRole.UserRole) + + if not item: + continue + + # Skip directories (items with left_icon) + if item.left_icon: + insert_pos = i + 1 + continue + + # This is a file - check its modification time + # Get the filename from display name + file_key = self._find_file_key_by_display_name(item.text) + if file_key: + file_data = self._files_data.get(file_key, {}) + file_modified = file_data.get("modified", 0) + + # Files are sorted newest first, so insert before older files + if modified_time > file_modified: + return i + + insert_pos = i + 1 + + return insert_pos + + def _find_file_key_by_display_name(self, display_name: str) -> typing.Optional[str]: + """Find the file key in _files_data by its display name.""" + for key in self._files_data: + if self._get_display_name(key) == display_name: + return key + return None + + @QtCore.pyqtSlot(dict, name="on_file_added") + def on_file_added(self, file_data: dict) -> None: + """ + Handle a single file being added. + + Called when Moonraker sends notify_filelist_changed with create_file action. + """ + path = file_data.get("path", file_data.get("filename", "")) + if not path or not path.lower().endswith(self.GCODE_EXTENSION): + return + + # Normalize paths + path = path.removeprefix("/") + file_dir = self._get_parent_directory(path) + current = self._curr_dir.removeprefix("/") + + # Check if file belongs to current directory + if file_dir != current: + return + + # Only update UI if visible + if not self.isVisible(): + return + + # Request metadata - the file will be added when on_fileinfo is called + if path not in self._pending_metadata_requests: + self._pending_metadata_requests.add(path) + self.request_file_info.emit(path) + self.request_file_metadata.emit(path) + + @QtCore.pyqtSlot(str, name="on_file_removed") + def on_file_removed(self, filepath: str) -> None: + """ + Handle a file being removed. + + Called when Moonraker sends notify_filelist_changed with delete_file action. + """ + filepath = filepath.removeprefix("/") + file_dir = self._get_parent_directory(filepath) + current = self._curr_dir.removeprefix("/") + + # Always clean up cache + self._files_data.pop(filepath, None) + self._pending_metadata_requests.discard(filepath) + self._metadata_retry_count.pop(filepath, None) + + # Only update UI if visible and in current directory + if not self.isVisible(): + return + + if file_dir != current: + return + + filename = self._get_basename(filepath) + display_name = self._get_display_name(filename) + + # Remove from model + removed = self._model.remove_item_by_text(display_name) + + if removed: + self._setup_scrollbar() + self._check_empty_state() + + @QtCore.pyqtSlot(dict, name="on_file_modified") + def on_file_modified(self, file_data: dict) -> None: + """ + Handle a file being modified. + + Called when Moonraker sends notify_filelist_changed with modify_file action. + """ + path = file_data.get("path", file_data.get("filename", "")) + if path: + # Remove old entry and request fresh metadata + self.on_file_removed(path) + self.on_file_added(file_data) + + @QtCore.pyqtSlot(dict, name="on_dir_added") + def on_dir_added(self, dir_data: dict) -> None: + """ + Handle a directory being added. + + Called when Moonraker sends notify_filelist_changed with create_dir action. + Inserts the directory in the correct sorted position (alphabetically, after Go Back). + """ + # Extract dirname from path or dirname field + path = dir_data.get("path", "") + dirname = dir_data.get("dirname", "") + + if not dirname and path: + dirname = self._get_basename(path) + + if not dirname or dirname.startswith("."): + return + + # Determine parent directory + path = path.removeprefix("/") + parent_dir = self._get_parent_directory(path) if path else "" + current = self._curr_dir.removeprefix("/") + + if parent_dir != current: + return + + # Skip UI update if not visible + if not self.isVisible(): + return + + # Check if already exists + if self._model_contains_item(dirname): + return + + # Ensure dirname is in dir_data + dir_data["dirname"] = dirname + + # Find the correct sorted position for this directory + insert_position = self._find_directory_insert_position(dirname) + + # Create the list item + icon = self._icons.get("folder") + if self._is_usb_directory(self._curr_dir, dirname): + icon = self._icons.get("usb") + item = ListItem( - text=name[:-6], - right_text=f"{filament_type} - {time_str}", - right_icon=self.path.get("right_arrow"), - left_icon=None, - callback=None, + text=str(dirname), + left_icon=icon, + right_text="", + right_icon=None, selected=False, + callback=None, allow_check=False, - _lfontsize=17, - _rfontsize=12, - height=80, - notificate=False, + _lfontsize=self.LEFT_FONT_SIZE, + _rfontsize=self.RIGHT_FONT_SIZE, + height=self.ITEM_HEIGHT, ) - self.model.add_item(item) + # Insert at the correct position + self._model.insert_item(insert_position, item) - @QtCore.pyqtSlot(str, name="file-item-clicked") - def _fileItemClicked(self, filename: str) -> None: - """Slot for List Item clicked + self._setup_scrollbar() + self._hide_placeholder() - Args: - filename (str): Clicked item path + def _find_directory_insert_position(self, new_dirname: str) -> int: + """ + Find the correct position to insert a new directory. + + Directories should be: + 1. After "Go Back" (if present) + 2. Before all files + 3. Sorted alphabetically among other directories + + Returns: + The index at which to insert the new directory. + """ + new_dirname_lower = new_dirname.lower() + insert_pos = 0 + + for i in range(self._model.rowCount()): + index = self._model.index(i) + item = self._model.data(index, QtCore.Qt.ItemDataRole.UserRole) + + if not item: + continue + + # Skip "Go Back" - always stays at top + if item.text == "Go Back": + insert_pos = i + 1 + continue + + # If this item has a left_icon, it's a directory + if item.left_icon: + # Compare alphabetically + if item.text.lower() > new_dirname_lower: + # Found a directory that should come after the new one + return i + else: + # This directory comes before, keep looking + insert_pos = i + 1 + else: + # Hit a file - insert before it (directories come before files) + return i + + # Insert at the end of directories (or end of list if no files) + return insert_pos + + @QtCore.pyqtSlot(str, name="on_dir_removed") + def on_dir_removed(self, dirname_or_path: str) -> None: + """ + Handle a directory being removed. + + Called when Moonraker sends notify_filelist_changed with delete_dir action. + Also handles USB mount removal (which Moonraker reports as delete_file). """ - self.file_selected.emit( - str(filename.removeprefix("/")), - self.files_data.get(filename.removeprefix("/")), + dirname_or_path = dirname_or_path.removeprefix("/") + dirname = ( + self._get_basename(dirname_or_path) + if "/" in dirname_or_path + else dirname_or_path ) - def _dirItemClicked(self, directory: str) -> None: - """Method that changes the current view in the list""" - self.curr_dir = self.curr_dir + directory - self.request_dir_info[str].emit(self.curr_dir) + if not dirname or not self.isVisible(): + return + + # Check if user is currently inside the removed directory (e.g., USB removed) + current = self._curr_dir.removeprefix("/") + if current == dirname or current.startswith(dirname + "/"): + logger.warning( + "Current directory '%s' was removed, returning to root", current + ) + self.on_directory_error() + self.back_btn.click() + return + removed = self._model.remove_item_by_text(dirname) + + if removed: + self._setup_scrollbar() + self._check_empty_state() + + @QtCore.pyqtSlot(name="on_full_refresh_needed") + def on_full_refresh_needed(self) -> None: + """ + Handle full refresh request. + + Called when Moonraker sends root_update or when major changes occur. + """ + logger.info("Full refresh requested") + self._curr_dir = "" + self.request_dir_info[str].emit(self._curr_dir) + + @QtCore.pyqtSlot(name="on_directory_error") + def on_directory_error(self) -> None: + """ + Handle Directory Error. + + Immediately navigates back to root gcodes folder. + Call this from MainWindow when detecting USB removal or directory errors. + """ + logger.error("Directory Error - returning to root directory") + + # Reset to root directory + self._curr_dir = "" + + # Clear any pending actions + self._pending_action = False + self._pending_metadata_requests.clear() + + # Request fresh data for root directory + self.request_dir_info[str].emit("") + + @QtCore.pyqtSlot(ListItem, name="on_item_selected") + def _on_item_selected(self, item: ListItem) -> None: + """Handle list item selection.""" + if not item.left_icon: + # File selected (files don't have left icon) + filename = self._build_filepath(item.text + self.GCODE_EXTENSION) + self._on_file_item_clicked(filename) + elif item.text == "Go Back": + # Go back selected + go_back_path = self._get_parent_directory(self._curr_dir) + if go_back_path == "/": + go_back_path = "" + self._on_go_back_dir(go_back_path) + else: + # Directory selected + self._on_dir_item_clicked("/" + item.text) + + @QtCore.pyqtSlot(name="reset_dir") + def reset_dir(self) -> None: + """Reset to root directory.""" + self._curr_dir = "" + self.request_dir_info[str].emit(self._curr_dir) + + def showEvent(self, event: QtGui.QShowEvent) -> None: + """Handle widget becoming visible.""" + # Request fresh data when becoming visible + self.request_dir_info[str].emit(self._curr_dir) + super().showEvent(event) + + def hideEvent(self, event: QtGui.QHideEvent) -> None: + """Handle widget being hidden.""" + # Clear pending requests when hidden + self._pending_metadata_requests.clear() + super().hideEvent(event) def _build_file_list(self) -> None: - """Inserts the currently available gcode files on the QListWidget""" - self.listWidget.blockSignals(True) - self.model.clear() - self.entry_delegate.clear() - if ( - not self.file_list - and not self.directories - and os.path.islink(self.curr_dir) - ): - self._add_placeholder() + """Build the complete file list display.""" + self._list_widget.blockSignals(True) + self._model.clear() + self._entry_delegate.clear() + self._pending_action = False + self._pending_metadata_requests.clear() + + # Determine if we're in root directory + is_root = not self._curr_dir or self._curr_dir == "/" + + # Check for empty state in root directory + if not self._file_list and not self._directories and is_root: + self._show_placeholder() + self._list_widget.blockSignals(False) return - if self.directories or self.curr_dir != "": - if self.curr_dir != "" and self.curr_dir != "/": - self._add_back_folder_entry() - for dir_data in self.directories: - if dir_data.get("dirname").startswith("."): - continue + # We have content (or we're in a subdirectory), hide placeholder + self._hide_placeholder() + + # Add back button if not in root + if not is_root: + self._add_back_folder_entry() + + # Add directories (sorted alphabetically) + sorted_dirs = sorted( + self._directories, key=lambda x: x.get("dirname", "").lower() + ) + for dir_data in sorted_dirs: + dirname = dir_data.get("dirname", "") + if dirname and not dirname.startswith("."): self._add_directory_list_item(dir_data) - sorted_list = sorted(self.file_list, key=lambda x: x["modified"], reverse=True) - for item in sorted_list: - self._add_file_list_item(item) + + # Add files (sorted by modification time, newest first) + sorted_files = sorted( + self._file_list, key=lambda x: x.get("modified", 0), reverse=True + ) + for file_item in sorted_files: + self._request_file_info(file_item) self._setup_scrollbar() - self.listWidget.blockSignals(False) - self.listWidget.update() + self._list_widget.blockSignals(False) + self._list_widget.update() + + def _create_file_list_item(self, filedata: dict) -> typing.Optional[ListItem]: + """Create a ListItem from file metadata.""" + filename = filedata.get("filename", "") + if not filename: + return None + + # Format estimated time + estimated_time = filedata.get("estimated_time", 0) + seconds = int(estimated_time) if isinstance(estimated_time, (int, float)) else 0 + time_str = self._format_print_time(seconds) + + # Get filament type + filament_type = filedata.get("filament_type") + if not filament_type or filament_type == -1.0 or filament_type == "Unknown": + filament_type = "Unknown filament" + + # Get display name (without path and .gcode extension) + display_name = self._get_display_name(filename) + + return ListItem( + text=display_name, + right_text=f"{filament_type} - {time_str}", + right_icon=self._icons.get("right_arrow"), + left_icon=None, # Files have no left icon + callback=None, + selected=False, + allow_check=False, + _lfontsize=self.LEFT_FONT_SIZE, + _rfontsize=self.RIGHT_FONT_SIZE, + height=self.ITEM_HEIGHT, + notificate=False, + ) def _add_directory_list_item(self, dir_data: dict) -> None: - """Method that adds directories to the list""" + """Add a directory entry to the list.""" dir_name = dir_data.get("dirname", "") if not dir_name: return + + # Choose appropriate icon + icon = self._icons.get("folder") + if self._is_usb_directory(self._curr_dir, dir_name): + icon = self._icons.get("usb") + item = ListItem( text=str(dir_name), - left_icon=self.path.get("folderIcon"), + left_icon=icon, right_text="", + right_icon=None, selected=False, callback=None, allow_check=False, - _lfontsize=17, - _rfontsize=12, - height=80, + _lfontsize=self.LEFT_FONT_SIZE, + _rfontsize=self.RIGHT_FONT_SIZE, + height=self.ITEM_HEIGHT, ) - self.model.add_item(item) + self._model.add_item(item) def _add_back_folder_entry(self) -> None: - """Method to insert in the list the "Go back" item""" - go_back_path = os.path.dirname(self.curr_dir) - if go_back_path == "/": - go_back_path = "" - + """Add the 'Go Back' navigation entry.""" item = ListItem( text="Go Back", right_text="", right_icon=None, - left_icon=self.path.get("back_folder"), + left_icon=self._icons.get("back_folder"), callback=None, selected=False, allow_check=False, - _lfontsize=17, - _rfontsize=12, - height=80, + _lfontsize=self.LEFT_FONT_SIZE, + _rfontsize=self.RIGHT_FONT_SIZE, + height=self.ITEM_HEIGHT, notificate=False, ) - self.model.add_item(item) + self._model.add_item(item) - @QtCore.pyqtSlot(str, str, name="on-goback-dir") - def _on_goback_dir(self, directory) -> None: - """Go back behaviour""" - self.request_dir_info[str].emit(directory) - self.curr_dir = directory - - def _add_file_list_item(self, file_data_item) -> None: - """Request file information and metadata to create filelist""" + def _request_file_info(self, file_data_item: dict) -> None: + """Request metadata for a file item.""" if not file_data_item: return - name = ( - file_data_item["path"] - if "path" in file_data_item.keys() - else file_data_item["filename"] - ) - if not name.endswith(".gcode"): + name = file_data_item.get("path", file_data_item.get("filename", "")) + if not name or not name.lower().endswith(self.GCODE_EXTENSION): return - file_path = ( - name if not self.curr_dir else str(self.curr_dir + "/" + name) - ).removeprefix("/") - self.request_file_metadata.emit(file_path.removeprefix("/")) - self.request_file_info.emit(file_path.removeprefix("/")) + # Build full path + file_path = self._build_filepath(name) - def _add_placeholder(self) -> None: - """Shows placeholder when no items exist""" - self.scrollbar.hide() - self.listWidget.hide() - self.label.show() + # Track pending request + self._pending_metadata_requests.add(file_path) - def _handle_scrollbar(self, value): - """Updates scrollbar value""" - self.scrollbar.blockSignals(True) - self.scrollbar.setValue(value) - self.scrollbar.blockSignals(False) + self.request_file_info.emit(file_path) + self.request_file_metadata.emit(file_path) + + def _on_file_item_clicked(self, filename: str) -> None: + """Handle file item click.""" + clean_filename = filename.removeprefix("/") + file_data = self._files_data.get(clean_filename, {}) + self.file_selected.emit(clean_filename, file_data) + + def _on_dir_item_clicked(self, directory: str) -> None: + """Handle directory item click.""" + if self._pending_action: + return + + self._curr_dir = self._curr_dir + directory + self.request_dir_info[str].emit(self._curr_dir) + self._pending_action = True + + def _on_go_back_dir(self, directory: str) -> None: + """Handle go back navigation.""" + self.request_dir_info[str].emit(directory) + self._curr_dir = directory + + def _show_placeholder(self) -> None: + """Show the 'No Files found' placeholder.""" + self._scrollbar.hide() + self._list_widget.hide() + self._label.show() + + def _hide_placeholder(self) -> None: + """Hide the placeholder and show the list.""" + self._label.hide() + self._list_widget.show() + self._scrollbar.show() + + def _check_empty_state(self) -> None: + """Check if list is empty and show placeholder if needed.""" + is_root = not self._curr_dir or self._curr_dir == "/" + + if self._model.rowCount() == 0 and is_root: + self._show_placeholder() + elif self._model.rowCount() == 0 and not is_root: + # In subdirectory with no files - just show "Go Back" + self._add_back_folder_entry() + + def _model_contains_item(self, text: str) -> bool: + """Check if model contains an item with the given text.""" + for i in range(self._model.rowCount()): + index = self._model.index(i) + item = self._model.data(index, QtCore.Qt.ItemDataRole.UserRole) + if item and item.text == text: + return True + return False + + def _handle_scrollbar_value_changed(self, value: int) -> None: + """Sync scrollbar with list widget.""" + self._scrollbar.blockSignals(True) + self._scrollbar.setValue(value) + self._scrollbar.blockSignals(False) def _setup_scrollbar(self) -> None: - """Syncs the scrollbar with the list size""" - self.scrollbar.setMinimum(self.listWidget.verticalScrollBar().minimum()) - self.scrollbar.setMaximum(self.listWidget.verticalScrollBar().maximum()) - self.scrollbar.setPageStep(self.listWidget.verticalScrollBar().pageStep()) - self.scrollbar.show() - - def _setupUI(self): - sizePolicy = QtWidgets.QSizePolicy( + """Configure scrollbar to match list size.""" + list_scrollbar = self._list_widget.verticalScrollBar() + self._scrollbar.setMinimum(list_scrollbar.minimum()) + self._scrollbar.setMaximum(list_scrollbar.maximum()) + self._scrollbar.setPageStep(list_scrollbar.pageStep()) + + if list_scrollbar.maximum() > 0: + self._scrollbar.show() + else: + self._scrollbar.hide() + + @staticmethod + def _get_basename(path: str) -> str: + """ + Get the basename of a path without using os.path. + Works with paths from Moonraker (forward slashes only). + """ + if not path: + return "" + # Remove trailing slashes and get last component + path = path.rstrip("/") + if "/" in path: + return path.rsplit("/", 1)[-1] + return path + + @staticmethod + def _get_parent_directory(path: str) -> str: + """ + Get the parent directory of a path without using os.path. + Works with paths from Moonraker (forward slashes only). + """ + if not path: + return "" + path = path.removeprefix("/").rstrip("/") + if "/" in path: + return path.rsplit("/", 1)[0] + return "" + + def _build_filepath(self, filename: str) -> str: + """Build full file path from current directory and filename.""" + filename = filename.removeprefix("/") + if self._curr_dir: + curr = self._curr_dir.removeprefix("/") + return f"{curr}/{filename}" + return filename + + @staticmethod + def _is_usb_directory(parent_dir: str, directory_name: str) -> bool: + """Check if directory is a USB mount in the root.""" + return parent_dir == "" and directory_name.startswith("USB-") + + @staticmethod + def _format_print_time(seconds: int) -> str: + """Format print time in human-readable form.""" + if seconds <= 0: + return "??" + if seconds < 60: + return "less than 1 minute" + + days, hours, minutes, _ = helper_methods.estimate_print_time(seconds) + + if days > 0: + return f"{days}d {hours}h {minutes}m" + elif hours > 0: + return f"{hours}h {minutes}m" + else: + return f"{minutes}m" + + def _get_display_name(self, filename: str) -> str: + """Get display name from filename (without path and extension).""" + basename = self._get_basename(filename) + name = helper_methods.get_file_name(basename) + + # Remove .gcode extension + if name.lower().endswith(self.GCODE_EXTENSION): + name = name[:-6] + + return name + + def _load_icons(self) -> None: + """Load all icons into cache.""" + self._icons = { + "back_folder": QtGui.QPixmap(self.ICON_PATHS["back_folder"]), + "folder": QtGui.QPixmap(self.ICON_PATHS["folder"]), + "right_arrow": QtGui.QPixmap(self.ICON_PATHS["right_arrow"]), + "usb": QtGui.QPixmap(self.ICON_PATHS["usb"]), + } + + def _connect_signals(self) -> None: + """Connect internal signals.""" + # Button connections + self._reload_button.clicked.connect( + lambda: self.request_dir_info[str].emit(self._curr_dir) + ) + self.back_btn.clicked.connect(self.reset_dir) + + # List widget connections + self._list_widget.verticalScrollBar().valueChanged.connect( + self._handle_scrollbar_value_changed + ) + self._scrollbar.valueChanged.connect(self._handle_scrollbar_value_changed) + self._scrollbar.valueChanged.connect( + lambda value: self._list_widget.verticalScrollBar().setValue(value) + ) + + # Delegate connections + self._entry_delegate.item_selected.connect(self._on_item_selected) + + def _setup_ui(self) -> None: + """Set up the widget UI.""" + # Size policy + size_policy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Policy.MinimumExpanding, QtWidgets.QSizePolicy.Policy.MinimumExpanding, ) - sizePolicy.setHorizontalStretch(1) - sizePolicy.setVerticalStretch(1) - sizePolicy.setHeightForWidth(self.sizePolicy().hasHeightForWidth()) - self.setSizePolicy(sizePolicy) + size_policy.setHorizontalStretch(1) + size_policy.setVerticalStretch(1) + self.setSizePolicy(size_policy) self.setMinimumSize(QtCore.QSize(710, 400)) + + # Font font = QtGui.QFont() font.setStyleStrategy(QtGui.QFont.StyleStrategy.PreferAntialias) self.setFont(font) + + # Layout direction and style self.setLayoutDirection(QtCore.Qt.LayoutDirection.LeftToRight) self.setAutoFillBackground(False) - self.setStyleSheet("#file_page{\n background-color: transparent;\n}") - self.verticalLayout_5 = QtWidgets.QVBoxLayout(self) - self.verticalLayout_5.setObjectName("verticalLayout_5") - self.fp_header_layout = QtWidgets.QHBoxLayout() - self.fp_header_layout.setObjectName("fp_header_layout") + self.setStyleSheet("#file_page { background-color: transparent; }") + + # Main layout + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setObjectName("main_layout") + + # Header layout + header_layout = self._create_header_layout() + main_layout.addLayout(header_layout) + + # Separator line + line = QtWidgets.QFrame(parent=self) + line.setFrameShape(QtWidgets.QFrame.Shape.HLine) + line.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) + main_layout.addWidget(line) + + # Content layout + content_layout = self._create_content_layout() + main_layout.addLayout(content_layout) + + def _create_header_layout(self) -> QtWidgets.QHBoxLayout: + """Create the header with back and reload buttons.""" + layout = QtWidgets.QHBoxLayout() + layout.setObjectName("header_layout") + + # Back button self.back_btn = IconButton(parent=self) self.back_btn.setMinimumSize(QtCore.QSize(60, 60)) self.back_btn.setMaximumSize(QtCore.QSize(60, 60)) self.back_btn.setFlat(True) - self.back_btn.setProperty( - "icon_pixmap", QtGui.QPixmap(":/ui/media/btn_icons/back.svg") - ) + self.back_btn.setProperty("icon_pixmap", QtGui.QPixmap(self.ICON_PATHS["back"])) self.back_btn.setObjectName("back_btn") - self.fp_header_layout.addWidget( - self.back_btn, 0, QtCore.Qt.AlignmentFlag.AlignLeft + layout.addWidget(self.back_btn, 0, QtCore.Qt.AlignmentFlag.AlignLeft) + + # Reload button + self._reload_button = IconButton(parent=self) + self._reload_button.setMinimumSize(QtCore.QSize(60, 60)) + self._reload_button.setMaximumSize(QtCore.QSize(60, 60)) + self._reload_button.setFlat(True) + self._reload_button.setProperty( + "icon_pixmap", QtGui.QPixmap(self.ICON_PATHS["refresh"]) ) - self.ReloadButton = IconButton(parent=self) - self.ReloadButton.setMinimumSize(QtCore.QSize(60, 60)) - self.ReloadButton.setMaximumSize(QtCore.QSize(60, 60)) - self.ReloadButton.setFlat(True) - self.ReloadButton.setProperty( - "icon_pixmap", QtGui.QPixmap(":/ui/media/btn_icons/refresh.svg") - ) - self.ReloadButton.setObjectName("ReloadButton") - self.fp_header_layout.addWidget( - self.ReloadButton, 0, QtCore.Qt.AlignmentFlag.AlignRight + self._reload_button.setObjectName("reload_button") + layout.addWidget(self._reload_button, 0, QtCore.Qt.AlignmentFlag.AlignRight) + + return layout + + def _create_content_layout(self) -> QtWidgets.QHBoxLayout: + """Create the content area with list and scrollbar.""" + layout = QtWidgets.QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setObjectName("content_layout") + + # Placeholder label + font = QtGui.QFont() + font.setPointSize(25) + self._label = QtWidgets.QLabel("No Files found") + self._label.setFont(font) + self._label.setStyleSheet("color: gray;") + self._label.hide() + + # List widget + self._list_widget = self._create_list_widget() + + # Scrollbar + self._scrollbar = CustomScrollBar() + self._scrollbar.show() + + # Add widgets to layout + layout.addWidget( + self._label, + alignment=( + QtCore.Qt.AlignmentFlag.AlignHCenter + | QtCore.Qt.AlignmentFlag.AlignVCenter + ), ) - self.verticalLayout_5.addLayout(self.fp_header_layout) - self.line = QtWidgets.QFrame(parent=self) - self.line.setMinimumSize(QtCore.QSize(0, 0)) - self.line.setFrameShape(QtWidgets.QFrame.Shape.HLine) - self.line.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) - self.line.setObjectName("line") - self.verticalLayout_5.addWidget(self.line) - self.fp_content_layout = QtWidgets.QHBoxLayout() - self.fp_content_layout.setContentsMargins(0, 0, 0, 0) - self.fp_content_layout.setObjectName("fp_content_layout") - self.listWidget = QtWidgets.QListView(parent=self) - self.listWidget.setModel(self.model) - self.listWidget.setItemDelegate(self.entry_delegate) - self.listWidget.setSpacing(5) - self.listWidget.setProperty("showDropIndicator", False) - self.listWidget.setProperty("selectionMode", "NoSelection") - self.listWidget.setStyleSheet("background: transparent;") - self.listWidget.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction) - self.listWidget.setUniformItemSizes(True) - self.listWidget.setObjectName("listWidget") - self.listWidget.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) - self.listWidget.setSelectionBehavior( + layout.addWidget(self._list_widget) + layout.addWidget(self._scrollbar) + + return layout + + def _create_list_widget(self) -> QtWidgets.QListView: + """Create and configure the list view widget.""" + list_widget = QtWidgets.QListView(parent=self) + list_widget.setModel(self._model) + list_widget.setItemDelegate(self._entry_delegate) + list_widget.setSpacing(5) + list_widget.setProperty("showDropIndicator", False) + list_widget.setProperty("selectionMode", "NoSelection") + list_widget.setStyleSheet("background: transparent;") + list_widget.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction) + list_widget.setUniformItemSizes(True) + list_widget.setObjectName("list_widget") + list_widget.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) + list_widget.setSelectionBehavior( QtWidgets.QAbstractItemView.SelectionBehavior.SelectItems ) - self.listWidget.setHorizontalScrollBarPolicy( + list_widget.setHorizontalScrollBarPolicy( QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) - self.listWidget.setVerticalScrollMode( + list_widget.setVerticalScrollMode( QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel ) - self.listWidget.setVerticalScrollBarPolicy( + list_widget.setVerticalScrollBarPolicy( QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) + list_widget.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + + # Enable touch gestures QtWidgets.QScroller.grabGesture( - self.listWidget, + list_widget, QtWidgets.QScroller.ScrollerGestureType.TouchGesture, ) QtWidgets.QScroller.grabGesture( - self.listWidget, + list_widget, QtWidgets.QScroller.ScrollerGestureType.LeftMouseButtonGesture, ) - self.listWidget.setEditTriggers( - QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers - ) - scroller_instance = QtWidgets.QScroller.scroller(self.listWidget) - scroller_props = scroller_instance.scrollerProperties() - scroller_props.setScrollMetric( + # Configure scroller properties + scroller = QtWidgets.QScroller.scroller(list_widget) + props = scroller.scrollerProperties() + props.setScrollMetric( QtWidgets.QScrollerProperties.ScrollMetric.DragVelocitySmoothingFactor, 0.05, ) - scroller_props.setScrollMetric( + props.setScrollMetric( QtWidgets.QScrollerProperties.ScrollMetric.DecelerationFactor, 0.4, ) - QtWidgets.QScroller.scroller(self.listWidget).setScrollerProperties( - scroller_props - ) - - font = QtGui.QFont() - font.setPointSize(25) - self.label = QtWidgets.QLabel("No Files found") - self.label.setFont(font) - self.label.setStyleSheet("color: gray;") - self.label.setMinimumSize( - QtCore.QSize(self.listWidget.width(), self.listWidget.height()) - ) - - self.scrollbar = CustomScrollBar() + scroller.setScrollerProperties(props) - self.fp_content_layout.addWidget( - self.label, - alignment=QtCore.Qt.AlignmentFlag.AlignHCenter - | QtCore.Qt.AlignmentFlag.AlignVCenter, - ) - self.fp_content_layout.addWidget(self.listWidget) - self.fp_content_layout.addWidget(self.scrollbar) - self.verticalLayout_5.addLayout(self.fp_content_layout) - self.scrollbar.show() - self.label.hide() - - self.path = { - "back_folder": QtGui.QPixmap(":/ui/media/btn_icons/back_folder.svg"), - "folderIcon": QtGui.QPixmap(":/ui/media/btn_icons/folderIcon.svg"), - "right_arrow": QtGui.QPixmap( - ":/arrow_icons/media/btn_icons/right_arrow.svg" - ), - } + return list_widget diff --git a/BlocksScreen/lib/ui/resources/icon_resources.qrc b/BlocksScreen/lib/ui/resources/icon_resources.qrc index a62dda06..85f7c261 100644 --- a/BlocksScreen/lib/ui/resources/icon_resources.qrc +++ b/BlocksScreen/lib/ui/resources/icon_resources.qrc @@ -84,6 +84,7 @@ media/btn_icons/unload_filament.svg + media/btn_icons/usb_icon.svg media/btn_icons/garbage-icon.svg media/btn_icons/back.svg media/btn_icons/refresh.svg diff --git a/BlocksScreen/lib/ui/resources/icon_resources_rc.py b/BlocksScreen/lib/ui/resources/icon_resources_rc.py index 9df24546..1cf84200 100644 --- a/BlocksScreen/lib/ui/resources/icon_resources_rc.py +++ b/BlocksScreen/lib/ui/resources/icon_resources_rc.py @@ -23626,6 +23626,101 @@ \x3d\x22\x74\x72\x61\x6e\x73\x6c\x61\x74\x65\x28\x33\x36\x39\x2e\ \x33\x36\x20\x37\x30\x35\x2e\x33\x38\x29\x20\x72\x6f\x74\x61\x74\ \x65\x28\x2d\x39\x30\x29\x22\x2f\x3e\x3c\x2f\x73\x76\x67\x3e\ +\x00\x00\x05\xc4\ +\x3c\ +\x3f\x78\x6d\x6c\x20\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\x31\x2e\ +\x30\x22\x20\x65\x6e\x63\x6f\x64\x69\x6e\x67\x3d\x22\x55\x54\x46\ +\x2d\x38\x22\x3f\x3e\x0a\x3c\x73\x76\x67\x20\x69\x64\x3d\x22\x4c\ +\x61\x79\x65\x72\x5f\x31\x22\x20\x64\x61\x74\x61\x2d\x6e\x61\x6d\ +\x65\x3d\x22\x4c\x61\x79\x65\x72\x20\x31\x22\x20\x78\x6d\x6c\x6e\ +\x73\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\x77\x33\ +\x2e\x6f\x72\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\x22\x20\x76\ +\x69\x65\x77\x42\x6f\x78\x3d\x22\x30\x20\x30\x20\x36\x30\x30\x20\ +\x36\x30\x30\x22\x3e\x0a\x20\x20\x3c\x64\x65\x66\x73\x3e\x0a\x20\ +\x20\x20\x20\x3c\x73\x74\x79\x6c\x65\x3e\x0a\x20\x20\x20\x20\x20\ +\x20\x2e\x63\x6c\x73\x2d\x31\x20\x7b\x0a\x20\x20\x20\x20\x20\x20\ +\x20\x20\x66\x69\x6c\x6c\x3a\x20\x23\x65\x30\x65\x30\x64\x66\x3b\ +\x0a\x20\x20\x20\x20\x20\x20\x7d\x0a\x20\x20\x20\x20\x3c\x2f\x73\ +\x74\x79\x6c\x65\x3e\x0a\x20\x20\x3c\x2f\x64\x65\x66\x73\x3e\x0a\ +\x20\x20\x3c\x72\x65\x63\x74\x20\x63\x6c\x61\x73\x73\x3d\x22\x63\ +\x6c\x73\x2d\x31\x22\x20\x78\x3d\x22\x34\x35\x36\x2e\x37\x22\x20\ +\x79\x3d\x22\x32\x35\x35\x2e\x39\x31\x22\x20\x77\x69\x64\x74\x68\ +\x3d\x22\x32\x31\x2e\x30\x31\x22\x20\x68\x65\x69\x67\x68\x74\x3d\ +\x22\x33\x30\x2e\x37\x35\x22\x20\x74\x72\x61\x6e\x73\x66\x6f\x72\ +\x6d\x3d\x22\x74\x72\x61\x6e\x73\x6c\x61\x74\x65\x28\x2d\x2e\x38\ +\x20\x31\x2e\x33\x39\x29\x20\x72\x6f\x74\x61\x74\x65\x28\x2d\x2e\ +\x31\x37\x29\x22\x2f\x3e\x0a\x20\x20\x3c\x72\x65\x63\x74\x20\x63\ +\x6c\x61\x73\x73\x3d\x22\x63\x6c\x73\x2d\x31\x22\x20\x78\x3d\x22\ +\x34\x35\x36\x2e\x37\x34\x22\x20\x79\x3d\x22\x33\x31\x32\x2e\x39\ +\x39\x22\x20\x77\x69\x64\x74\x68\x3d\x22\x32\x31\x2e\x30\x36\x22\ +\x20\x68\x65\x69\x67\x68\x74\x3d\x22\x33\x30\x2e\x37\x33\x22\x20\ +\x74\x72\x61\x6e\x73\x66\x6f\x72\x6d\x3d\x22\x74\x72\x61\x6e\x73\ +\x6c\x61\x74\x65\x28\x2d\x2e\x36\x39\x20\x2e\x39\x38\x29\x20\x72\ +\x6f\x74\x61\x74\x65\x28\x2d\x2e\x31\x32\x29\x22\x2f\x3e\x0a\x20\ +\x20\x3c\x70\x61\x74\x68\x20\x63\x6c\x61\x73\x73\x3d\x22\x63\x6c\ +\x73\x2d\x31\x22\x20\x64\x3d\x22\x4d\x35\x34\x38\x2e\x31\x32\x2c\ +\x33\x38\x35\x2e\x38\x37\x6c\x2d\x2e\x31\x39\x2d\x31\x37\x32\x2e\ +\x33\x2d\x31\x31\x34\x2e\x38\x31\x2e\x31\x63\x2e\x35\x36\x2d\x32\ +\x33\x2e\x39\x31\x2d\x31\x36\x2e\x33\x2d\x34\x32\x2e\x30\x33\x2d\ +\x34\x30\x2e\x30\x34\x2d\x34\x31\x2e\x38\x34\x6c\x2d\x33\x30\x34\ +\x2e\x34\x31\x2e\x34\x37\x63\x2d\x32\x33\x2e\x30\x31\x2e\x30\x33\ +\x2d\x33\x36\x2e\x38\x35\x2c\x32\x30\x2e\x34\x32\x2d\x33\x36\x2e\ +\x37\x39\x2c\x34\x31\x2e\x32\x32\x6c\x2e\x34\x38\x2c\x31\x37\x37\ +\x2e\x39\x37\x68\x30\x63\x2e\x30\x36\x2c\x32\x31\x2e\x38\x35\x2c\ +\x31\x39\x2e\x30\x33\x2c\x33\x36\x2e\x37\x31\x2c\x33\x39\x2e\x31\ +\x39\x2c\x33\x36\x2e\x36\x39\x6c\x33\x30\x33\x2e\x31\x36\x2d\x2e\ +\x33\x31\x63\x32\x33\x2e\x31\x2d\x2e\x35\x33\x2c\x33\x39\x2e\x32\ +\x32\x2d\x31\x38\x2e\x35\x37\x2c\x33\x38\x2e\x35\x38\x2d\x34\x31\ +\x2e\x38\x36\x6c\x31\x31\x34\x2e\x38\x34\x2d\x2e\x31\x34\x5a\x4d\ +\x31\x30\x39\x2e\x30\x38\x2c\x33\x34\x31\x2e\x33\x31\x63\x2e\x30\ +\x31\x2c\x38\x2e\x34\x35\x2d\x34\x2e\x39\x37\x2c\x31\x34\x2e\x31\ +\x38\x2d\x31\x32\x2e\x30\x38\x2c\x31\x34\x2e\x36\x31\x2d\x36\x2e\ +\x39\x35\x2e\x34\x32\x2d\x31\x33\x2e\x38\x32\x2d\x34\x2e\x38\x39\ +\x2d\x31\x33\x2e\x38\x34\x2d\x31\x32\x2e\x36\x32\x6c\x2d\x2e\x31\ +\x34\x2d\x38\x35\x2e\x30\x31\x63\x2d\x2e\x30\x31\x2d\x37\x2e\x37\ +\x38\x2c\x35\x2e\x31\x37\x2d\x31\x33\x2e\x35\x36\x2c\x31\x32\x2e\ +\x35\x35\x2d\x31\x33\x2e\x39\x33\x2c\x37\x2e\x35\x31\x2d\x2e\x33\ +\x38\x2c\x31\x33\x2e\x34\x33\x2c\x35\x2e\x36\x39\x2c\x31\x33\x2e\ +\x34\x33\x2c\x31\x33\x2e\x38\x33\x6c\x2e\x30\x38\x2c\x38\x33\x2e\ +\x31\x31\x68\x2d\x2e\x30\x31\x5a\x4d\x33\x36\x31\x2e\x38\x34\x2c\ +\x33\x31\x38\x2e\x34\x34\x6c\x2d\x2e\x33\x34\x2d\x31\x33\x2e\x32\ +\x2d\x31\x31\x32\x2e\x34\x36\x2e\x35\x2c\x32\x34\x2e\x36\x36\x2c\ +\x32\x35\x2e\x35\x33\x2c\x34\x32\x2e\x32\x2d\x2e\x31\x39\x2e\x33\ +\x33\x2d\x31\x31\x2e\x35\x35\x2c\x33\x32\x2e\x39\x2d\x2e\x32\x2e\ +\x32\x32\x2c\x33\x33\x2e\x30\x36\x2d\x33\x32\x2e\x39\x33\x2e\x32\ +\x38\x2d\x2e\x33\x38\x2d\x31\x31\x2e\x35\x33\x2d\x34\x33\x2e\x30\ +\x34\x2e\x32\x32\x63\x2d\x32\x2e\x30\x31\x2e\x30\x31\x2d\x35\x2e\ +\x34\x35\x2d\x31\x2e\x38\x37\x2d\x36\x2e\x39\x37\x2d\x33\x2e\x34\ +\x32\x6c\x2d\x33\x31\x2e\x33\x39\x2d\x33\x32\x2e\x31\x33\x2d\x34\ +\x34\x2e\x35\x39\x2e\x31\x37\x63\x2d\x33\x2e\x30\x39\x2c\x31\x33\ +\x2e\x31\x2d\x31\x34\x2e\x36\x31\x2c\x32\x30\x2e\x38\x34\x2d\x32\ +\x36\x2e\x38\x33\x2c\x31\x39\x2e\x35\x37\x2d\x31\x32\x2e\x35\x32\ +\x2d\x31\x2e\x33\x2d\x32\x31\x2e\x38\x2d\x31\x31\x2e\x38\x33\x2d\ +\x32\x32\x2e\x30\x36\x2d\x32\x33\x2e\x39\x31\x2d\x2e\x32\x37\x2d\ +\x31\x32\x2e\x37\x38\x2c\x39\x2e\x31\x35\x2d\x32\x33\x2e\x34\x33\ +\x2c\x32\x31\x2e\x33\x2d\x32\x34\x2e\x39\x39\x2c\x31\x32\x2e\x36\ +\x36\x2d\x31\x2e\x36\x32\x2c\x32\x34\x2e\x31\x34\x2c\x36\x2c\x32\ +\x37\x2e\x35\x32\x2c\x31\x39\x2e\x31\x38\x6c\x32\x31\x2e\x31\x34\ +\x2e\x30\x38\x2c\x33\x31\x2e\x37\x36\x2d\x33\x33\x2e\x38\x37\x63\ +\x31\x2e\x35\x35\x2d\x31\x2e\x36\x35\x2c\x35\x2e\x30\x32\x2d\x32\ +\x2e\x38\x32\x2c\x37\x2e\x31\x39\x2d\x32\x2e\x38\x32\x6c\x32\x39\ +\x2e\x31\x32\x2d\x2e\x30\x35\x63\x33\x2e\x32\x39\x2d\x37\x2e\x38\ +\x39\x2c\x31\x30\x2d\x31\x32\x2e\x37\x2c\x31\x38\x2e\x33\x2d\x31\ +\x31\x2e\x37\x2c\x37\x2e\x36\x35\x2e\x39\x32\x2c\x31\x34\x2e\x30\ +\x39\x2c\x37\x2e\x32\x36\x2c\x31\x34\x2e\x36\x33\x2c\x31\x35\x2e\ +\x34\x2e\x35\x33\x2c\x37\x2e\x39\x31\x2d\x34\x2e\x36\x31\x2c\x31\ +\x35\x2e\x34\x35\x2d\x31\x32\x2e\x32\x2c\x31\x37\x2e\x33\x34\x2d\ +\x38\x2e\x30\x36\x2c\x32\x2e\x30\x31\x2d\x31\x36\x2e\x36\x38\x2d\ +\x2e\x38\x33\x2d\x32\x30\x2e\x32\x2d\x31\x30\x2e\x38\x35\x6c\x2d\ +\x32\x39\x2e\x38\x35\x2d\x2e\x30\x39\x2d\x32\x34\x2e\x31\x32\x2c\ +\x32\x36\x2e\x34\x39\x2c\x31\x33\x35\x2e\x32\x36\x2d\x2e\x34\x39\ +\x2e\x37\x38\x2d\x31\x33\x2e\x31\x33\x2c\x33\x31\x2e\x34\x33\x2c\ +\x31\x38\x2e\x30\x32\x2d\x33\x31\x2e\x34\x31\x2c\x31\x38\x2e\x33\ +\x31\x5a\x4d\x34\x33\x32\x2e\x38\x33\x2c\x32\x33\x32\x2e\x34\x37\ +\x6c\x39\x36\x2e\x33\x38\x2d\x2e\x31\x36\x2e\x32\x32\x2c\x31\x33\ +\x34\x2e\x37\x36\x2d\x39\x36\x2e\x33\x38\x2e\x31\x36\x2d\x2e\x32\ +\x32\x2d\x31\x33\x34\x2e\x37\x36\x5a\x22\x2f\x3e\x0a\x3c\x2f\x73\ +\x76\x67\x3e\ \x00\x00\x03\x4a\ \x3c\ \x73\x76\x67\x20\x69\x64\x3d\x22\x4c\x61\x79\x65\x72\x5f\x31\x22\ @@ -25959,6 +26054,10 @@ \x09\xcb\x31\x47\ \x00\x4c\ \x00\x43\x00\x44\x00\x5f\x00\x73\x00\x65\x00\x74\x00\x74\x00\x69\x00\x6e\x00\x67\x00\x73\x00\x2e\x00\x73\x00\x76\x00\x67\ +\x00\x0c\ +\x09\xf2\x51\x27\ +\x00\x75\ +\x00\x73\x00\x62\x00\x5f\x00\x69\x00\x63\x00\x6f\x00\x6e\x00\x2e\x00\x73\x00\x76\x00\x67\ \x00\x12\ \x0a\x4f\x00\xc7\ \x00\x73\ @@ -26211,7 +26310,7 @@ \x00\x00\x01\xb0\x00\x02\x00\x00\x00\x01\x00\x00\x00\x99\ \x00\x00\x11\xd0\x00\x00\x00\x00\x00\x01\x00\x05\x4a\xa5\ \x00\x00\x01\xa0\x00\x02\x00\x00\x00\x01\x00\x00\x00\x9b\ -\x00\x00\x01\xb0\x00\x02\x00\x00\x00\x28\x00\x00\x00\x9c\ +\x00\x00\x01\xb0\x00\x02\x00\x00\x00\x29\x00\x00\x00\x9c\ \x00\x00\x11\xf0\x00\x00\x00\x00\x00\x01\x00\x05\x4f\x6b\ \x00\x00\x12\x06\x00\x00\x00\x00\x00\x01\x00\x05\x57\x1f\ \x00\x00\x12\x1e\x00\x00\x00\x00\x00\x01\x00\x05\x59\x44\ @@ -26231,27 +26330,28 @@ \x00\x00\x13\xc0\x00\x00\x00\x00\x00\x01\x00\x05\xa5\x14\ \x00\x00\x13\xd8\x00\x00\x00\x00\x00\x01\x00\x05\xa8\xd7\ \x00\x00\x13\xfe\x00\x00\x00\x00\x00\x01\x00\x05\xb2\x8b\ -\x00\x00\x14\x28\x00\x00\x00\x00\x00\x01\x00\x05\xb5\xd9\ -\x00\x00\x14\x4a\x00\x00\x00\x00\x00\x01\x00\x05\xb9\x67\ -\x00\x00\x14\x70\x00\x00\x00\x00\x00\x01\x00\x05\xbe\x08\ -\x00\x00\x14\x84\x00\x00\x00\x00\x00\x01\x00\x05\xc7\xda\ -\x00\x00\x14\xb0\x00\x00\x00\x00\x00\x01\x00\x05\xcd\x24\ -\x00\x00\x14\xd8\x00\x00\x00\x00\x00\x01\x00\x05\xd3\x2b\ -\x00\x00\x14\xee\x00\x00\x00\x00\x00\x01\x00\x05\xd4\x0f\ -\x00\x00\x15\x1a\x00\x00\x00\x00\x00\x01\x00\x05\xd6\x58\ -\x00\x00\x15\x30\x00\x00\x00\x00\x00\x01\x00\x05\xdd\x08\ -\x00\x00\x15\x4c\x00\x00\x00\x00\x00\x01\x00\x05\xe0\x4c\ -\x00\x00\x15\x64\x00\x00\x00\x00\x00\x01\x00\x05\xe1\x78\ -\x00\x00\x15\x7e\x00\x00\x00\x00\x00\x01\x00\x05\xe7\x39\ -\x00\x00\x15\xa0\x00\x00\x00\x00\x00\x01\x00\x05\xe8\x5b\ -\x00\x00\x15\xbe\x00\x00\x00\x00\x00\x01\x00\x05\xee\x4e\ -\x00\x00\x15\xde\x00\x00\x00\x00\x00\x01\x00\x05\xf1\x52\ -\x00\x00\x16\x00\x00\x00\x00\x00\x00\x01\x00\x05\xf2\x73\ -\x00\x00\x16\x20\x00\x00\x00\x00\x00\x01\x00\x05\xf5\x47\ -\x00\x00\x16\x4e\x00\x00\x00\x00\x00\x01\x00\x05\xfd\xb3\ -\x00\x00\x16\x72\x00\x00\x00\x00\x00\x01\x00\x06\x05\x73\ -\x00\x00\x16\x96\x00\x00\x00\x00\x00\x01\x00\x06\x0a\x8e\ -\x00\x00\x16\xbe\x00\x00\x00\x00\x00\x01\x00\x06\x0b\xe1\ +\x00\x00\x14\x1c\x00\x00\x00\x00\x00\x01\x00\x05\xb8\x53\ +\x00\x00\x14\x46\x00\x00\x00\x00\x00\x01\x00\x05\xbb\xa1\ +\x00\x00\x14\x68\x00\x00\x00\x00\x00\x01\x00\x05\xbf\x2f\ +\x00\x00\x14\x8e\x00\x00\x00\x00\x00\x01\x00\x05\xc3\xd0\ +\x00\x00\x14\xa2\x00\x00\x00\x00\x00\x01\x00\x05\xcd\xa2\ +\x00\x00\x14\xce\x00\x00\x00\x00\x00\x01\x00\x05\xd2\xec\ +\x00\x00\x14\xf6\x00\x00\x00\x00\x00\x01\x00\x05\xd8\xf3\ +\x00\x00\x15\x0c\x00\x00\x00\x00\x00\x01\x00\x05\xd9\xd7\ +\x00\x00\x15\x38\x00\x00\x00\x00\x00\x01\x00\x05\xdc\x20\ +\x00\x00\x15\x4e\x00\x00\x00\x00\x00\x01\x00\x05\xe2\xd0\ +\x00\x00\x15\x6a\x00\x00\x00\x00\x00\x01\x00\x05\xe6\x14\ +\x00\x00\x15\x82\x00\x00\x00\x00\x00\x01\x00\x05\xe7\x40\ +\x00\x00\x15\x9c\x00\x00\x00\x00\x00\x01\x00\x05\xed\x01\ +\x00\x00\x15\xbe\x00\x00\x00\x00\x00\x01\x00\x05\xee\x23\ +\x00\x00\x15\xdc\x00\x00\x00\x00\x00\x01\x00\x05\xf4\x16\ +\x00\x00\x15\xfc\x00\x00\x00\x00\x00\x01\x00\x05\xf7\x1a\ +\x00\x00\x16\x1e\x00\x00\x00\x00\x00\x01\x00\x05\xf8\x3b\ +\x00\x00\x16\x3e\x00\x00\x00\x00\x00\x01\x00\x05\xfb\x0f\ +\x00\x00\x16\x6c\x00\x00\x00\x00\x00\x01\x00\x06\x03\x7b\ +\x00\x00\x16\x90\x00\x00\x00\x00\x00\x01\x00\x06\x0b\x3b\ +\x00\x00\x16\xb4\x00\x00\x00\x00\x00\x01\x00\x06\x10\x56\ +\x00\x00\x16\xdc\x00\x00\x00\x00\x00\x01\x00\x06\x11\xa9\ " qt_resource_struct_v2 = b"\ @@ -26368,9 +26468,9 @@ \x00\x00\x01\xb0\x00\x02\x00\x00\x00\x02\x00\x00\x00\x38\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x05\xfc\x00\x00\x00\x00\x00\x01\x00\x02\x3f\x46\ -\x00\x00\x01\x9b\xbc\x28\x2f\x35\ +\x00\x00\x01\x9b\xc1\x5d\xd5\xcd\ \x00\x00\x06\x2a\x00\x00\x00\x00\x00\x01\x00\x02\x41\x71\ -\x00\x00\x01\x9b\xbc\x28\x2f\x35\ +\x00\x00\x01\x9b\xc1\x5d\xd5\xcd\ \x00\x00\x01\xa0\x00\x02\x00\x00\x00\x02\x00\x00\x00\x3b\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x01\xb0\x00\x02\x00\x00\x00\x06\x00\x00\x00\x44\ @@ -26516,7 +26616,7 @@ \x00\x00\x01\xb0\x00\x02\x00\x00\x00\x09\x00\x00\x00\x82\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x0f\x50\x00\x00\x00\x00\x00\x01\x00\x04\xaa\xfb\ -\x00\x00\x01\x9b\x7f\x73\xe2\xad\ +\x00\x00\x01\x9b\xe1\x68\x3a\x17\ \x00\x00\x0f\x70\x00\x01\x00\x00\x00\x01\x00\x04\xad\xa7\ \x00\x00\x01\x9a\x72\xe1\x94\x5b\ \x00\x00\x0f\x94\x00\x00\x00\x00\x00\x01\x00\x04\xb8\xf8\ @@ -26526,11 +26626,11 @@ \x00\x00\x0f\xd2\x00\x00\x00\x00\x00\x01\x00\x04\xd1\x9d\ \x00\x00\x01\x9a\x72\xe1\x94\x5b\ \x00\x00\x0f\xf6\x00\x00\x00\x00\x00\x01\x00\x04\xdb\x1e\ -\x00\x00\x01\x9b\x7f\x73\xe2\xad\ +\x00\x00\x01\x9b\xe1\x68\x3a\x17\ \x00\x00\x10\x16\x00\x00\x00\x00\x00\x01\x00\x04\xe0\xbb\ \x00\x00\x01\x9a\x72\xe1\x94\x5b\ \x00\x00\x10\x3e\x00\x00\x00\x00\x00\x01\x00\x04\xea\x84\ -\x00\x00\x01\x9b\x7f\x73\xe2\xad\ +\x00\x00\x01\x9b\xe1\x68\x3a\x17\ \x00\x00\x10\x5e\x00\x00\x00\x00\x00\x01\x00\x04\xee\x67\ \x00\x00\x01\x9a\x72\xe1\x94\x53\ \x00\x00\x01\xa0\x00\x02\x00\x00\x00\x01\x00\x00\x00\x8c\ @@ -26565,7 +26665,7 @@ \x00\x00\x01\x9a\x72\xe1\x94\x4f\ \x00\x00\x01\xa0\x00\x02\x00\x00\x00\x01\x00\x00\x00\x9b\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x01\xb0\x00\x02\x00\x00\x00\x28\x00\x00\x00\x9c\ +\x00\x00\x01\xb0\x00\x02\x00\x00\x00\x29\x00\x00\x00\x9c\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x11\xf0\x00\x00\x00\x00\x00\x01\x00\x05\x4f\x6b\ \x00\x00\x01\x9a\x72\xe1\x94\x5b\ @@ -26580,7 +26680,7 @@ \x00\x00\x12\x92\x00\x00\x00\x00\x00\x01\x00\x05\x67\x46\ \x00\x00\x01\x9a\x72\xe1\x94\x53\ \x00\x00\x12\xa8\x00\x00\x00\x00\x00\x01\x00\x05\x68\x36\ -\x00\x00\x01\x9b\xbc\x0f\x8a\x2e\ +\x00\x00\x01\x9b\xc1\x5d\xd5\xcd\ \x00\x00\x12\xbe\x00\x00\x00\x00\x00\x01\x00\x05\x6c\x5f\ \x00\x00\x01\x9a\x72\xe1\x94\x5b\ \x00\x00\x12\xe4\x00\x00\x00\x00\x00\x01\x00\x05\x72\x96\ @@ -26594,7 +26694,7 @@ \x00\x00\x13\x4c\x00\x00\x00\x00\x00\x01\x00\x05\x95\x9c\ \x00\x00\x01\x9a\x72\xe1\x94\x4f\ \x00\x00\x13\x7e\x00\x00\x00\x00\x00\x01\x00\x05\x98\xeb\ -\x00\x00\x01\x9b\xbc\x0f\x8a\x2e\ +\x00\x00\x01\x9b\xc1\x5d\xd5\xc9\ \x00\x00\x13\x96\x00\x00\x00\x00\x00\x01\x00\x05\x9d\x0c\ \x00\x00\x01\x9a\x72\xe1\x94\x4b\ \x00\x00\x13\xac\x00\x00\x00\x00\x00\x01\x00\x05\xa3\x03\ @@ -26604,48 +26704,50 @@ \x00\x00\x13\xd8\x00\x00\x00\x00\x00\x01\x00\x05\xa8\xd7\ \x00\x00\x01\x9a\x72\xe1\x94\x4b\ \x00\x00\x13\xfe\x00\x00\x00\x00\x00\x01\x00\x05\xb2\x8b\ +\x00\x00\x01\x9b\xeb\x47\xad\xf0\ +\x00\x00\x14\x1c\x00\x00\x00\x00\x00\x01\x00\x05\xb8\x53\ \x00\x00\x01\x9a\x72\xe1\x94\x57\ -\x00\x00\x14\x28\x00\x00\x00\x00\x00\x01\x00\x05\xb5\xd9\ +\x00\x00\x14\x46\x00\x00\x00\x00\x00\x01\x00\x05\xbb\xa1\ \x00\x00\x01\x9a\x72\xe1\x94\x57\ -\x00\x00\x14\x4a\x00\x00\x00\x00\x00\x01\x00\x05\xb9\x67\ +\x00\x00\x14\x68\x00\x00\x00\x00\x00\x01\x00\x05\xbf\x2f\ \x00\x00\x01\x9a\x72\xe1\x94\x4f\ -\x00\x00\x14\x70\x00\x00\x00\x00\x00\x01\x00\x05\xbe\x08\ +\x00\x00\x14\x8e\x00\x00\x00\x00\x00\x01\x00\x05\xc3\xd0\ \x00\x00\x01\x9a\x72\xe1\x94\x57\ -\x00\x00\x14\x84\x00\x00\x00\x00\x00\x01\x00\x05\xc7\xda\ +\x00\x00\x14\xa2\x00\x00\x00\x00\x00\x01\x00\x05\xcd\xa2\ \x00\x00\x01\x9a\x72\xe1\x94\x53\ -\x00\x00\x14\xb0\x00\x00\x00\x00\x00\x01\x00\x05\xcd\x24\ +\x00\x00\x14\xce\x00\x00\x00\x00\x00\x01\x00\x05\xd2\xec\ \x00\x00\x01\x9a\x72\xe1\x94\x57\ -\x00\x00\x14\xd8\x00\x00\x00\x00\x00\x01\x00\x05\xd3\x2b\ +\x00\x00\x14\xf6\x00\x00\x00\x00\x00\x01\x00\x05\xd8\xf3\ \x00\x00\x01\x9a\x72\xe1\x94\x57\ -\x00\x00\x14\xee\x00\x00\x00\x00\x00\x01\x00\x05\xd4\x0f\ +\x00\x00\x15\x0c\x00\x00\x00\x00\x00\x01\x00\x05\xd9\xd7\ \x00\x00\x01\x9a\x72\xe1\x94\x4f\ -\x00\x00\x15\x1a\x00\x00\x00\x00\x00\x01\x00\x05\xd6\x58\ +\x00\x00\x15\x38\x00\x00\x00\x00\x00\x01\x00\x05\xdc\x20\ \x00\x00\x01\x9a\x72\xe1\x94\x5b\ -\x00\x00\x15\x30\x00\x00\x00\x00\x00\x01\x00\x05\xdd\x08\ +\x00\x00\x15\x4e\x00\x00\x00\x00\x00\x01\x00\x05\xe2\xd0\ \x00\x00\x01\x9a\x72\xe1\x94\x57\ -\x00\x00\x15\x4c\x00\x00\x00\x00\x00\x01\x00\x05\xe0\x4c\ +\x00\x00\x15\x6a\x00\x00\x00\x00\x00\x01\x00\x05\xe6\x14\ \x00\x00\x01\x9a\x72\xe1\x94\x53\ -\x00\x00\x15\x64\x00\x00\x00\x00\x00\x01\x00\x05\xe1\x78\ +\x00\x00\x15\x82\x00\x00\x00\x00\x00\x01\x00\x05\xe7\x40\ \x00\x00\x01\x9a\x72\xe1\x94\x53\ -\x00\x00\x15\x7e\x00\x00\x00\x00\x00\x01\x00\x05\xe7\x39\ +\x00\x00\x15\x9c\x00\x00\x00\x00\x00\x01\x00\x05\xed\x01\ \x00\x00\x01\x9a\x72\xe1\x94\x57\ -\x00\x00\x15\xa0\x00\x00\x00\x00\x00\x01\x00\x05\xe8\x5b\ +\x00\x00\x15\xbe\x00\x00\x00\x00\x00\x01\x00\x05\xee\x23\ \x00\x00\x01\x9a\x72\xe1\x94\x57\ -\x00\x00\x15\xbe\x00\x00\x00\x00\x00\x01\x00\x05\xee\x4e\ +\x00\x00\x15\xdc\x00\x00\x00\x00\x00\x01\x00\x05\xf4\x16\ \x00\x00\x01\x9a\x72\xe1\x94\x53\ -\x00\x00\x15\xde\x00\x00\x00\x00\x00\x01\x00\x05\xf1\x52\ +\x00\x00\x15\xfc\x00\x00\x00\x00\x00\x01\x00\x05\xf7\x1a\ \x00\x00\x01\x9a\x72\xe1\x94\x57\ -\x00\x00\x16\x00\x00\x00\x00\x00\x00\x01\x00\x05\xf2\x73\ +\x00\x00\x16\x1e\x00\x00\x00\x00\x00\x01\x00\x05\xf8\x3b\ \x00\x00\x01\x9a\x72\xe1\x94\x57\ -\x00\x00\x16\x20\x00\x00\x00\x00\x00\x01\x00\x05\xf5\x47\ +\x00\x00\x16\x3e\x00\x00\x00\x00\x00\x01\x00\x05\xfb\x0f\ \x00\x00\x01\x9a\x72\xe1\x94\x53\ -\x00\x00\x16\x4e\x00\x00\x00\x00\x00\x01\x00\x05\xfd\xb3\ +\x00\x00\x16\x6c\x00\x00\x00\x00\x00\x01\x00\x06\x03\x7b\ \x00\x00\x01\x9a\x72\xe1\x94\x4f\ -\x00\x00\x16\x72\x00\x00\x00\x00\x00\x01\x00\x06\x05\x73\ +\x00\x00\x16\x90\x00\x00\x00\x00\x00\x01\x00\x06\x0b\x3b\ \x00\x00\x01\x9a\x72\xe1\x94\x53\ -\x00\x00\x16\x96\x00\x00\x00\x00\x00\x01\x00\x06\x0a\x8e\ +\x00\x00\x16\xb4\x00\x00\x00\x00\x00\x01\x00\x06\x10\x56\ \x00\x00\x01\x9a\x72\xe1\x94\x4f\ -\x00\x00\x16\xbe\x00\x00\x00\x00\x00\x01\x00\x06\x0b\xe1\ +\x00\x00\x16\xdc\x00\x00\x00\x00\x00\x01\x00\x06\x11\xa9\ \x00\x00\x01\x9a\x72\xe1\x94\x4b\ " diff --git a/BlocksScreen/lib/ui/resources/media/btn_icons/usb_icon.svg b/BlocksScreen/lib/ui/resources/media/btn_icons/usb_icon.svg new file mode 100644 index 00000000..3ea01599 --- /dev/null +++ b/BlocksScreen/lib/ui/resources/media/btn_icons/usb_icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/BlocksScreen/lib/utils/list_model.py b/BlocksScreen/lib/utils/list_model.py index 2f4cbc3a..4399f350 100644 --- a/BlocksScreen/lib/utils/list_model.py +++ b/BlocksScreen/lib/utils/list_model.py @@ -55,6 +55,34 @@ def add_item(self, item: ListItem) -> None: self.entries.append(item) self.endInsertRows() + def remove_item_by_text(self, text: str) -> bool: + """Remove item from model by its text value. + + Args: + text: The text value of the item to remove. + + Returns: + True if item was found and removed, False otherwise. + """ + for i, item in enumerate(self.entries): + if item.text == text: + self.beginRemoveRows(QtCore.QModelIndex(), i, i) + self.entries.pop(i) + self.endRemoveRows() + return True + return False + + def insert_item(self, position: int, item: ListItem) -> None: + """Insert item at a specific position in the model.""" + if position < 0: + position = 0 + if position > len(self.entries): + position = len(self.entries) + + self.beginInsertRows(QtCore.QModelIndex(), position, position) + self.entries.insert(position, item) + self.endInsertRows() + def flags(self, index) -> QtCore.Qt.ItemFlag: """Models item flags, re-implemented method""" item = self.entries[index.row()] From aed3037dba8341bb75bfa54b93bb5f6a3b605ae8 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Thu, 29 Jan 2026 11:11:53 +0000 Subject: [PATCH 2/2] bugfix: generation of new .code files and handling of new files introduced, in case of not loading the thumbnail of a file always shows the blocksthumbnail file --- BlocksScreen/lib/files.py | 314 +++++++++++------- BlocksScreen/lib/panels/mainWindow.py | 32 +- BlocksScreen/lib/panels/printTab.py | 7 + .../lib/panels/widgets/confirmPage.py | 17 +- BlocksScreen/lib/panels/widgets/filesPage.py | 290 ++++++++++++---- 5 files changed, 445 insertions(+), 215 deletions(-) diff --git a/BlocksScreen/lib/files.py b/BlocksScreen/lib/files.py index 89a545f2..f40ecec8 100644 --- a/BlocksScreen/lib/files.py +++ b/BlocksScreen/lib/files.py @@ -46,11 +46,9 @@ def from_string(cls, action: str) -> "FileAction": @dataclass class FileMetadata: """ - `Data class for file metadata.` + Data class for file metadata. - All data comes from Moonraker API - no local filesystem access. - Thumbnails are stored as ThumbnailInfo objects with paths that can - be fetched via Moonraker's /server/files/gcodes/ endpoint. + Thumbnails are stored as QImage objects when available. """ filename: str = "" @@ -155,40 +153,33 @@ def safe_get(key: str, default: typing.Any) -> typing.Any: class Files(QtCore.QObject): """ - `Manages gcode files with event-driven updates.` - - Architecture: - 1. On WebSocket connection: requests full file list once via initial_load() - 2. On notify_filelist_changed: updates internal state incrementally - 3. Emits signals for UI components to react to changes - - Signals emitted: - - on_dirs: Full directory list (for initial load) - - on_file_list: Full file list (for initial load) - - fileinfo: Single file metadata (when metadata is received) - - file_added: Single file was added - - file_removed: Single file was removed - - file_modified: Single file was modified - - dir_added: Single directory was added - - dir_removed: Single directory was removed - - full_refresh_needed: Root changed, need complete refresh + Manages gcode files with event-driven updates. + E + Signals emitted: + - on_dirs: Full directory list + - on_file_list: Full file list + - fileinfo: Single file metadata update + - file_added/removed/modified: Incremental updates + - dir_added/removed: Directory updates + - full_refresh_needed: Root changed """ - # Signals for API requests (to Moonraker) + # Signals for API requests request_file_list = QtCore.pyqtSignal([], [str], name="api_get_files_list") request_dir_info = QtCore.pyqtSignal( [], [str], [str, bool], name="api_get_dir_info" ) request_file_metadata = QtCore.pyqtSignal(str, name="get_file_metadata") - request_thumbnail = QtCore.pyqtSignal(str, name="request_thumbnail") - request_file_download = QtCore.pyqtSignal(str, str, name="file_download") - # Signals for UI updates (full refresh) + # Signals for UI updates on_dirs = QtCore.pyqtSignal(list, name="on_dirs") on_file_list = QtCore.pyqtSignal(list, name="on_file_list") fileinfo = QtCore.pyqtSignal(dict, name="fileinfo") + metadata_error = QtCore.pyqtSignal( + str, name="metadata_error" + ) # filename when metadata fails - # Signals for incremental updates (event-driven) + # Signals for incremental updates file_added = QtCore.pyqtSignal(dict, name="file_added") file_removed = QtCore.pyqtSignal(str, name="file_removed") file_modified = QtCore.pyqtSignal(dict, name="file_modified") @@ -196,7 +187,10 @@ class Files(QtCore.QObject): dir_removed = QtCore.pyqtSignal(str, name="dir_removed") full_refresh_needed = QtCore.pyqtSignal(name="full_refresh_needed") - # Constants + # Signal for preloaded USB files + usb_files_loaded = QtCore.pyqtSignal( + str, list, name="usb_files_loaded" + ) # (usb_path, files) GCODE_EXTENSION = ".gcode" GCODE_PATH = "~/printer_data/gcodes" @@ -204,17 +198,22 @@ def __init__(self, parent: QtCore.QObject, ws: MoonWebSocket) -> None: super().__init__(parent) self.ws = ws - # Internal state - use instance variables, not class variables! - self._files: dict[str, dict] = {} # filename -> file data - self._directories: dict[str, dict] = {} # dirname -> dir data - self._files_metadata: dict[str, FileMetadata] = {} # filename -> metadata + # Internal state + self._files: dict[str, dict] = {} + self._directories: dict[str, dict] = {} + self._files_metadata: dict[str, FileMetadata] = {} self._current_directory: str = "" self._initial_load_complete: bool = False - - self.gcode_path = os.path.expanduser("~/printer_data/gcodes") + self.gcode_path = os.path.expanduser(self.GCODE_PATH) + # USB preloaded files cache: usb_path -> list of files + self._usb_files_cache: dict[str, list[dict]] = {} + # Track pending USB preload requests + self._pending_usb_preloads: set[str] = set() + # Track the last USB preload request for response matching + self._last_usb_preload_request: str = "" self._connect_signals() - QtWidgets.QApplication.instance().installEventFilter(self) # type: ignore + self._install_event_filter() def _connect_signals(self) -> None: """Connect internal signals to websocket API.""" @@ -224,8 +223,12 @@ def _connect_signals(self) -> None: self.request_dir_info[str, bool].connect(self.ws.api.get_dir_information) self.request_dir_info[str].connect(self.ws.api.get_dir_information) self.request_file_metadata.connect(self.ws.api.get_gcode_metadata) - self.request_thumbnail.connect(self.ws.api.get_gcode_thumbnail) - self.request_file_download.connect(self.ws.api.download_file) + + def _install_event_filter(self) -> None: + """Install event filter on application instance.""" + app = QtWidgets.QApplication.instance() + if app: + app.installEventFilter(self) @property def file_list(self) -> list[dict]: @@ -254,7 +257,7 @@ def is_loaded(self) -> bool: def get_file_metadata(self, filename: str) -> typing.Optional[FileMetadata]: """Get cached metadata for a file.""" - return self._files_metadata.get(filename) + return self._files_metadata.get(filename.removeprefix("/")) def get_file_data(self, filename: str) -> dict: """Get cached file data dict for a file.""" @@ -265,47 +268,29 @@ def get_file_data(self, filename: str) -> dict: return {} def refresh_directory(self, directory: str = "") -> None: - """ - Force refresh of a specific directory. - Use sparingly - prefer event-driven updates. - """ + """Force refresh of a specific directory.""" + logger.debug(f"Refreshing directory: {directory or 'root'}") self._current_directory = directory self.request_dir_info[str, bool].emit(directory, True) def initial_load(self) -> None: - """Perform initial load of file list. Call once on connection.""" + """Perform initial load of file list.""" logger.info("Performing initial file list load") self._initial_load_complete = False self.request_dir_info[str, bool].emit("", True) def handle_filelist_changed(self, data: typing.Union[dict, list]) -> None: - """ - Handle notify_filelist_changed from Moonraker. - - This is the main entry point for event-driven updates. - Called from your websocket message handler when receiving - 'notify_filelist_changed' notifications. - - Args: - data: The notification data from Moonraker (various formats supported). - """ - # Handle nested "params" key (full JSON-RPC envelope) + """Handle notify_filelist_changed from Moonraker.""" if isinstance(data, dict) and "params" in data: data = data.get("params", []) - # Handle list format if isinstance(data, list): if len(data) > 0: data = data[0] else: - logger.warning("Received empty list in filelist_changed") return - # Validate we have a dict if not isinstance(data, dict): - logger.warning( - "Unexpected data type in filelist_changed: %s", type(data).__name__ - ) return action_str = data.get("action", "") @@ -313,7 +298,7 @@ def handle_filelist_changed(self, data: typing.Union[dict, list]) -> None: item = data.get("item", {}) source_item = data.get("source_item", {}) - logger.debug("File list changed: action=%s, item=%s", action_str, item) + logger.debug(f"File list changed: action={action_str}, item={item}") handlers = { FileAction.CREATE_FILE: self._handle_file_created, @@ -329,8 +314,6 @@ def handle_filelist_changed(self, data: typing.Union[dict, list]) -> None: handler = handlers.get(action) if handler: handler(item, source_item) - else: - logger.warning("Unknown file action: %s", action_str) def _handle_file_created(self, item: dict, _: dict) -> None: """Handle new file creation.""" @@ -338,26 +321,20 @@ def _handle_file_created(self, item: dict, _: dict) -> None: if not path: return - # Check if this is actually a USB mount (Moonraker reports USB as files) - # USB mounts: path like "USB-sda1" with no extension, at root level if self._is_usb_mount(path): - # Treat as directory instead item["dirname"] = path self._handle_dir_created(item, {}) return - # Only process gcode files if not path.lower().endswith(self.GCODE_EXTENSION): return - # Add to internal state self._files[path] = item - - # Emit signal for UI and request metadata self.file_added.emit(item) - self.request_file_metadata.emit(path.removeprefix("/")) - logger.info("File created: %s", path) + # Request metadata (will update later) + self.request_file_metadata.emit(path.removeprefix("/")) + logger.info(f"File created: {path}") def _handle_file_deleted(self, item: dict, _: dict) -> None: """Handle file deletion.""" @@ -365,19 +342,16 @@ def _handle_file_deleted(self, item: dict, _: dict) -> None: if not path: return - # Check if this is actually a USB mount being removed if self._is_usb_mount(path): item["dirname"] = path self._handle_dir_deleted(item, {}) return - # Remove from internal state self._files.pop(path, None) self._files_metadata.pop(path.removeprefix("/"), None) - # Emit signal for UI self.file_removed.emit(path) - logger.info("File deleted: %s", path) + logger.info(f"File deleted: {path}") def _handle_file_modified(self, item: dict, _: dict) -> None: """Handle file modification.""" @@ -385,72 +359,63 @@ def _handle_file_modified(self, item: dict, _: dict) -> None: if not path or not path.lower().endswith(self.GCODE_EXTENSION): return - # Update internal state self._files[path] = item - - # Clear cached metadata and request fresh self._files_metadata.pop(path.removeprefix("/"), None) - # Emit signal and request new metadata self.request_file_metadata.emit(path.removeprefix("/")) self.file_modified.emit(item) - logger.info("File modified: %s", path) + logger.info(f"File modified: {path}") def _handle_file_moved(self, item: dict, source_item: dict) -> None: """Handle file move/rename.""" old_path = source_item.get("path", "") new_path = item.get("path", "") - # Remove from old location if old_path: self._handle_file_deleted(source_item, {}) - - # Add to new location if new_path: self._handle_file_created(item, {}) - logger.info("File moved: %s -> %s", old_path, new_path) - def _handle_dir_created(self, item: dict, _: dict) -> None: """Handle directory creation.""" path = item.get("path", "") dirname = item.get("dirname", "") - # Extract dirname from path if not provided if not dirname and path: dirname = path.rstrip("/").split("/")[-1] if not dirname or dirname.startswith("."): return - # Ensure dirname is in item for UI item["dirname"] = dirname - - # Add to internal state self._directories[dirname] = item - - # Emit signal for UI self.dir_added.emit(item) - logger.info("Directory created: %s", dirname) + logger.info(f"Directory created: {dirname}") + + if self._is_usb_mount(dirname): + self._preload_usb_contents(dirname) def _handle_dir_deleted(self, item: dict, _: dict) -> None: """Handle directory deletion.""" path = item.get("path", "") dirname = item.get("dirname", "") - # Extract dirname from path if not provided if not dirname and path: dirname = path.rstrip("/").split("/")[-1] if not dirname: return - # Remove from internal state self._directories.pop(dirname, None) - # Emit signal for UI + # Clear USB cache if this was a USB mount + if self._is_usb_mount(dirname): + self._usb_files_cache.pop(dirname, None) + self._pending_usb_preloads.discard(dirname) + logger.info(f"Cleared USB cache for: {dirname}") + self.dir_removed.emit(dirname) - logger.info("Directory deleted: %s", dirname) + logger.info(f"Directory deleted: {dirname}") def _handle_dir_moved(self, item: dict, source_item: dict) -> None: """Handle directory move/rename.""" @@ -458,27 +423,15 @@ def _handle_dir_moved(self, item: dict, source_item: dict) -> None: self._handle_dir_created(item, {}) def _handle_root_update(self, _: dict, __: dict) -> None: - """Handle root update - requires full refresh.""" + """Handle root update.""" logger.info("Root update detected, requesting full refresh") self.full_refresh_needed.emit() self.initial_load() @staticmethod def _is_usb_mount(path: str) -> bool: - """ - Check if a path is a USB mount point. - - Moonraker incorrectly reports USB mounts as files with create_file/delete_file. - USB mounts have paths like "USB-sda1" - starting with "USB-" and at root level. - - Args: - path: The file path to check - - Returns: - True if this appears to be a USB mount point - """ + """Check if a path is a USB mount point.""" path = path.removeprefix("/") - # USB mounts are at root level (no slashes) and start with "USB-" return "/" not in path and path.startswith("USB-") def handle_message_received( @@ -500,20 +453,21 @@ def _process_file_list(self, data: list) -> None: path = item.get("path", item.get("filename", "")) if path: self._files[path] = item - # Request metadata for each file - self.request_file_metadata.emit(path.removeprefix("/")) self._initial_load_complete = True self.on_file_list.emit(self.file_list) - logger.info("Loaded %d files", len(self._files)) + logger.info(f"Loaded {len(self._files)} files") + # Request metadata only for gcode files (async update) + for path in self._files: + if path.lower().endswith(self.GCODE_EXTENSION): + self.request_file_metadata.emit(path.removeprefix("/")) def _process_metadata(self, data: dict) -> None: - """Process file metadata response from Moonraker.""" + """Process file metadata response.""" filename = data.get("filename") if not filename: return - # Create metadata from Moonraker response (no local filesystem access) thumbnails = data.get("thumbnails", []) base_dir = os.path.dirname(os.path.join(self.gcode_path, filename)) thumbnail_paths = [ @@ -531,41 +485,149 @@ def _process_metadata(self, data: dict) -> None: metadata = FileMetadata.from_dict(data, thumbnail_images) self._files_metadata[filename] = metadata + + # Emit updated fileinfo self.fileinfo.emit(metadata.to_dict()) + logger.debug(f"Metadata loaded for: {filename}") + + def handle_metadata_error(self, error_data: typing.Union[str, dict]) -> None: + """ + Handle metadata request error from Moonraker. + + Parses the filename from the error message and emits metadata_error signal. + Called directly from MainWindow error handler. + + Args: + error_data: The error message string or dict from Moonraker + """ + if not error_data: + return + + if isinstance(error_data, dict): + text = error_data.get("message", str(error_data)) + else: + text = str(error_data) + + if "metadata" not in text.lower(): + return + + # Parse filename from error message (format: ) + start = text.find("<") + 1 + end = text.find(">", start) + + if start > 0 and end > start: + filename = text[start:end] + clean_filename = filename.removeprefix("/") + self.metadata_error.emit(clean_filename) + logger.debug(f"Metadata error for: {clean_filename}") + + def _preload_usb_contents(self, usb_path: str) -> None: + """ + Preload USB contents when USB is inserted. + + Requests directory info for the USB mount so files are ready + when user navigates to it. + + Args: + usb_path: The USB mount path (e.g., "USB-sda1") + """ + logger.info(f"Preloading USB contents: {usb_path}") + self._pending_usb_preloads.add(usb_path) + # Store which USB we're preloading (for response matching) + self._last_usb_preload_request = usb_path + self.ws.api.get_dir_information(usb_path, True) + + def get_cached_usb_files(self, usb_path: str) -> typing.Optional[list[dict]]: + """ + Get cached files for a USB path if available. + + Args: + usb_path: The USB mount path + + Returns: + List of file dicts if cached, None otherwise + """ + return self._usb_files_cache.get(usb_path.removeprefix("/")) + + def _process_usb_directory_info(self, usb_path: str, data: dict) -> None: + """ + Process preloaded USB directory info. + + Caches the files and requests metadata for gcode files. + + Args: + usb_path: The USB mount path + data: Directory info response from Moonraker + """ + files = [] + for file_data in data.get("files", []): + filename = file_data.get("filename", file_data.get("path", "")) + if filename: + files.append(file_data) + + full_path = f"{usb_path}/{filename}" + if filename.lower().endswith(self.GCODE_EXTENSION): + self.request_file_metadata.emit(full_path) + + # Cache the files + self._usb_files_cache[usb_path] = files + self.usb_files_loaded.emit(usb_path, files) + logger.info(f"Preloaded {len(files)} files from USB: {usb_path}") def _process_directory_info(self, data: dict) -> None: """Process directory info response.""" + # Check if this is a USB preload response + matched_usb = None + + if self._pending_usb_preloads: + # Check if this response matches last USB preload request + if self._last_usb_preload_request in self._pending_usb_preloads: + matched_usb = self._last_usb_preload_request + self._last_usb_preload_request = "" + else: + # Fallback: check root_info for USB marker + root_info = data.get("root_info", {}) + root_name = root_info.get("name", "") + for usb_path in list(self._pending_usb_preloads): + if root_name.startswith("USB-") or usb_path in root_name: + matched_usb = usb_path + break + + if matched_usb: + self._pending_usb_preloads.discard(matched_usb) + self._process_usb_directory_info(matched_usb, data) + return + self._directories.clear() self._files.clear() - # Process directories for dir_data in data.get("dirs", []): dirname = dir_data.get("dirname", "") if dirname and not dirname.startswith("."): self._directories[dirname] = dir_data - # Process files for file_data in data.get("files", []): filename = file_data.get("filename", file_data.get("path", "")) if filename: self._files[filename] = file_data - # Emit signals for UI self.on_file_list.emit(self.file_list) self.on_dirs.emit(self.directories) self._initial_load_complete = True logger.info( - "Directory loaded: %d dirs, %d files", - len(self._directories), - len(self._files), + f"Directory loaded: {len(self._directories)} dirs, {len(self._files)} files" ) + # Request metadata only for gcode files (async update) + for filename in self._files: + if filename.lower().endswith(self.GCODE_EXTENSION): + self.request_file_metadata.emit(filename.removeprefix("/")) + @QtCore.pyqtSlot(str, str, name="on_request_delete_file") def on_request_delete_file(self, filename: str, directory: str = "gcodes") -> None: """Request deletion of a file.""" if not filename: - logger.warning("Attempted to delete file with empty filename") return if directory: @@ -573,7 +635,7 @@ def on_request_delete_file(self, filename: str, directory: str = "gcodes") -> No else: self.ws.api.delete_file(filename) - logger.info("Requested deletion of: %s", filename) + logger.info(f"Requested deletion of: {filename}") @QtCore.pyqtSlot(str, name="on_request_fileinfo") def on_request_fileinfo(self, filename: str) -> None: @@ -592,11 +654,10 @@ def on_request_fileinfo(self, filename: str) -> None: def get_dir_information( self, directory: str = "", extended: bool = True ) -> typing.Optional[list]: - """Get directory information - from cache or request from Moonraker.""" + """Get directory information.""" self._current_directory = directory if not extended and self._initial_load_complete: - # Return cached data if available and extended info not needed return self.directories return self.ws.api.get_dir_information(directory, extended) @@ -626,5 +687,8 @@ def _clear_all_data(self) -> None: self._files.clear() self._directories.clear() self._files_metadata.clear() + self._usb_files_cache.clear() + self._pending_usb_preloads.clear() + self._last_usb_preload_request = "" self._initial_load_complete = False logger.info("All file data cleared") diff --git a/BlocksScreen/lib/panels/mainWindow.py b/BlocksScreen/lib/panels/mainWindow.py index 3d3d9f94..afd6948e 100644 --- a/BlocksScreen/lib/panels/mainWindow.py +++ b/BlocksScreen/lib/panels/mainWindow.py @@ -578,7 +578,6 @@ def _handle_notify_klippy_message(self, method, data, metadata) -> None: @api_handler def _handle_notify_filelist_changed_message(self, method, data, metadata) -> None: """Handle websocket file list messages""" - print(data) self.file_data.handle_filelist_changed(data) @api_handler @@ -618,30 +617,31 @@ def _handle_notify_gcode_response_message(self, method, data, metadata) -> None: @api_handler def _handle_error_message(self, method, data, metadata) -> None: - """Handle error messages""" + """Handle error messages from Moonraker API.""" self.handle_error_response[list].emit([data, metadata]) if self._popup_toggle: return - text = data - if isinstance(data, dict): - if "message" in data: - text = f"{data['message']}" - else: - text = data + + text = data.get("message", str(data)) if isinstance(data, dict) else str(data) lower_text = text.lower() + + # Metadata errors - silent, handled by files_manager if "metadata" in lower_text: - start = text.find("<") + 1 - end = text.find(">", start) - path = text[start:end] if start > 0 and end > start else "" - self.printPanel.filesPage_widget.request_metadata(path) + self.file_data.handle_metadata_error(text) return - elif "does not exist" in lower_text: - if "file" in lower_text: - return + + # File not found - silent + if "file" in lower_text and "does not exist" in lower_text: + return + + # Directory not found - navigate back + show popup + if "does not exist" in lower_text: self.printPanel.filesPage_widget.back_btn.click() + + # Show popup for all other errors (including directory errors) self.popup.new_message( message_type=Popup.MessageType.ERROR, - message=str(text), + message=text, userInput=True, ) _logger.error(text) diff --git a/BlocksScreen/lib/panels/printTab.py b/BlocksScreen/lib/panels/printTab.py index bb0ea9a2..d1b94b1a 100644 --- a/BlocksScreen/lib/panels/printTab.py +++ b/BlocksScreen/lib/panels/printTab.py @@ -127,6 +127,10 @@ def __init__( self.filesPage_widget.request_dir_info[str].connect( self.file_data.request_dir_info[str] ) + self.filesPage_widget.request_scan_metadata.connect( + self.ws.api.scan_gcode_metadata + ) + self.file_data.metadata_error.connect(self.filesPage_widget.on_metadata_error) self.filesPage_widget.request_dir_info.connect(self.file_data.request_dir_info) self.file_data.on_file_list.connect(self.filesPage_widget.on_file_list) self.file_data.file_added.connect(self.filesPage_widget.on_file_added) @@ -137,6 +141,9 @@ def __init__( self.file_data.full_refresh_needed.connect( self.filesPage_widget.on_full_refresh_needed ) + self.file_data.usb_files_loaded.connect( + self.filesPage_widget.on_usb_files_loaded + ) self.jobStatusPage_widget = JobStatusWidget(self) self.addWidget(self.jobStatusPage_widget) self.confirmPage_widget.on_accept.connect( diff --git a/BlocksScreen/lib/panels/widgets/confirmPage.py b/BlocksScreen/lib/panels/widgets/confirmPage.py index 12432449..0f35ba39 100644 --- a/BlocksScreen/lib/panels/widgets/confirmPage.py +++ b/BlocksScreen/lib/panels/widgets/confirmPage.py @@ -25,7 +25,7 @@ def __init__(self, parent) -> None: self._setupUI() self.setMouseTracking(True) self.setAttribute(QtCore.Qt.WidgetAttribute.WA_AcceptTouchEvents, True) - self.thumbnail: QtGui.QImage = QtGui.QImage() + self.thumbnail: QtGui.QImage = self._blocksthumbnail self._thumbnails: typing.List = [] self.directory = "gcodes" self.filename = "" @@ -42,17 +42,19 @@ def __init__(self, parent) -> None: @QtCore.pyqtSlot(str, dict, name="on_show_widget") def on_show_widget(self, text: str, filedata: dict | None = None) -> None: """Handle widget show""" + if not filedata: + return directory = os.path.dirname(text) filename = os.path.basename(text) self.directory = directory self.filename = filename self.cf_file_name.setText(self.filename) - if not filedata: - return self._thumbnails = filedata.get("thumbnail_images", []) if self._thumbnails: _biggest_thumbnail = self._thumbnails[-1] # Show last which is biggest self.thumbnail = QtGui.QImage(_biggest_thumbnail) + else: + self.thumbnail = self._blocksthumbnail _total_filament = filedata.get("filament_weight_total") _estimated_time = filedata.get("estimated_time") if isinstance(_estimated_time, str): @@ -114,12 +116,6 @@ def paintEvent(self, event: QtGui.QPaintEvent) -> None: self._scene = QtWidgets.QGraphicsScene(self) self.cf_thumbnail.setScene(self._scene) - # Pick thumbnail or fallback logo - if self.thumbnail.isNull(): - self.thumbnail = QtGui.QImage( - "BlocksScreen/lib/ui/resources/media/logoblocks400x300.png" - ) - # Scene rectangle (available display area) graphics_rect = self.cf_thumbnail.rect().toRectF() @@ -323,3 +319,6 @@ def _setupUI(self) -> None: QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter, ) self.verticalLayout_4.addLayout(self.cf_content_vertical_layout) + self._blocksthumbnail = QtGui.QImage( + "BlocksScreen/lib/ui/resources/media/logoblocks400x300.png" + ) diff --git a/BlocksScreen/lib/panels/widgets/filesPage.py b/BlocksScreen/lib/panels/widgets/filesPage.py index ecc4b36a..63ce0aa6 100644 --- a/BlocksScreen/lib/panels/widgets/filesPage.py +++ b/BlocksScreen/lib/panels/widgets/filesPage.py @@ -1,3 +1,4 @@ +import json import logging import typing @@ -7,7 +8,7 @@ from lib.utils.list_model import EntryDelegate, EntryListModel, ListItem from PyQt6 import QtCore, QtGui, QtWidgets -logger = logging.getLogger("logs/BlocksScreen.log") +logger = logging.getLogger(__name__) class FilesPage(QtWidgets.QWidget): @@ -17,7 +18,6 @@ class FilesPage(QtWidgets.QWidget): This widget displays a list of gcode files and directories, allowing navigation and file selection. It receives updates from the Files manager via signals. - Signals emitted: - request_back: User wants to go back (header button) - file_selected(str, dict): User selected a file @@ -35,6 +35,7 @@ class FilesPage(QtWidgets.QWidget): ) request_file_list = QtCore.pyqtSignal([], [str], name="api_get_files_list") request_file_metadata = QtCore.pyqtSignal(str, name="api_get_gcode_metadata") + request_scan_metadata = QtCore.pyqtSignal(str, name="api_scan_gcode_metadata") # Constants GCODE_EXTENSION = ".gcode" @@ -43,7 +44,7 @@ class FilesPage(QtWidgets.QWidget): LEFT_FONT_SIZE = 17 RIGHT_FONT_SIZE = 12 - # Icon paths - centralized for easy modification + # Icon paths ICON_PATHS = { "back_folder": ":/ui/media/btn_icons/back_folder.svg", "folder": ":/ui/media/btn_icons/folderIcon.svg", @@ -56,26 +57,24 @@ class FilesPage(QtWidgets.QWidget): def __init__(self, parent: typing.Optional[QtWidgets.QWidget] = None) -> None: super().__init__(parent) - # Instance data - NOT class-level to avoid sharing between instances self._file_list: list[dict] = [] self._files_data: dict[str, dict] = {} # filename -> metadata dict self._directories: list[dict] = [] self._curr_dir: str = "" self._pending_action: bool = False self._pending_metadata_requests: set[str] = set() # Track pending requests - self._metadata_retry_count: dict[str, int] = {} # Track retry count per file + self._metadata_retry_count: dict[ + str, int + ] = {} # Track retry count per file (max 3) self._icons: dict[str, QtGui.QPixmap] = {} - # Model and delegate self._model = EntryListModel() self._entry_delegate = EntryDelegate() - # Setup UI self._setup_ui() self._load_icons() self._connect_signals() - # Widget attributes self.setMouseTracking(True) self.setAttribute(QtCore.Qt.WidgetAttribute.WA_AcceptTouchEvents, True) @@ -91,6 +90,7 @@ def current_directory(self, value: str) -> None: def reload_gcodes_folder(self) -> None: """Request reload of the gcodes folder from root.""" + logger.debug("Reloading gcodes folder") self.request_dir_info[str].emit("") def clear_files_data(self) -> None: @@ -99,43 +99,50 @@ def clear_files_data(self) -> None: self._pending_metadata_requests.clear() self._metadata_retry_count.clear() - def request_metadata(self, file_path: str) -> bool: + def retry_metadata_request(self, file_path: str) -> bool: """ Request metadata with a maximum of 3 retries per file. - - Used by error handlers to retry metadata requests that failed. - Args: file_path: Path to the file - Returns: - True if request was made (under retry limit), False if limit reached + True if request was made, False if max retries reached """ clean_path = file_path.removeprefix("/") + if not clean_path.lower().endswith(self.GCODE_EXTENSION): + return False + current_count = self._metadata_retry_count.get(clean_path, 0) - if current_count < 3: - self._metadata_retry_count[clean_path] = current_count + 1 - self.request_file_metadata.emit(clean_path) - return True - logger.warning("Metadata retry limit reached for: %s", clean_path) - return False + if current_count > 3: + # Maximum 3 force scan per file + logger.debug(f"Metadata retry limit reached for: {clean_path}") + return False - def reset_metadata_retry(self, file_path: str) -> None: - """Reset the retry counter for a specific file.""" - clean_path = file_path.removeprefix("/") - self._metadata_retry_count.pop(clean_path, None) + self._metadata_retry_count[clean_path] = current_count + 1 + + if current_count == 0: + # First attempt: regular metadata request + self.request_file_metadata.emit(clean_path) + logger.debug(f"Metadata request 1/3 for: {clean_path}") + else: + # Second and third attempts: force scan + self.request_scan_metadata.emit(clean_path) + logger.debug(f"Metadata scan {current_count + 1}/3 for: {clean_path}") + + return True @QtCore.pyqtSlot(list, name="on_file_list") def on_file_list(self, file_list: list) -> None: """Handle receiving full files list.""" self._file_list = file_list.copy() if file_list else [] + logger.debug(f"Received file list with {len(self._file_list)} files") @QtCore.pyqtSlot(list, name="on_dirs") def on_directories(self, directories_data: list) -> None: """Handle receiving full directories list.""" self._directories = directories_data.copy() if directories_data else [] + logger.debug(f"Received {len(self._directories)} directories") if self.isVisible(): self._build_file_list() @@ -145,22 +152,21 @@ def on_fileinfo(self, filedata: dict) -> None: """ Handle receiving file metadata. - This is called both during initial load and when new files are added. - Creates/updates the file entry in the list. - Inserts files in sorted position (by modification time, newest first). + Updates existing file entry in the list with better info (time, filament). """ if not filedata or not self.isVisible(): return filename = filedata.get("filename", "") - if not filename: + if not filename or not filename.lower().endswith(self.GCODE_EXTENSION): return # Cache the file data self._files_data[filename] = filedata - # Remove from pending requests + # Remove from pending requests and reset retry count (success) self._pending_metadata_requests.discard(filename) + self._metadata_retry_count.pop(filename, None) # Check if this file should be displayed in current view file_dir = self._get_parent_directory(filename) @@ -170,22 +176,61 @@ def on_fileinfo(self, filedata: dict) -> None: if file_dir != current: return - # Check if item already exists in model display_name = self._get_display_name(filename) - if self._model_contains_item(display_name): - # Item exists, update it by removing and re-adding - self._model.remove_item_by_text(display_name) - - # Create the list item item = self._create_file_list_item(filedata) - if item: - # Find correct position (sorted by modification time, newest first) - insert_position = self._find_file_insert_position( - filedata.get("modified", 0) - ) - self._model.insert_item(insert_position, item) - self._setup_scrollbar() - self._hide_placeholder() + if not item: + return + + self._model.remove_item_by_text(display_name) + + # Find correct position (sorted by modification time, newest first) + insert_position = self._find_file_insert_position(filedata.get("modified", 0)) + self._model.insert_item(insert_position, item) + + logger.debug(f"Updated file in list: {display_name}") + + @QtCore.pyqtSlot(str, name="on_metadata_error") + def on_metadata_error(self, filename: str) -> None: + """ + Handle metadata request failure. + + Triggers retry with scan_gcode_metadata if under retry limit. + Called when metadata request fails. + """ + if not filename: + return + + clean_filename = filename.removeprefix("/") + + if clean_filename not in self._pending_metadata_requests: + return + + # Try again (will use force scan to the max of 3 times) + if not self.retry_metadata_request(clean_filename): + # Max retries reached, remove from pending + self._pending_metadata_requests.discard(clean_filename) + logger.debug(f"Giving up on metadata for: {clean_filename}") + + @QtCore.pyqtSlot(str, list, name="on_usb_files_loaded") + def on_usb_files_loaded(self, usb_path: str, files: list) -> None: + """ + Handle preloaded USB files. + + Called when USB files are preloaded in background. + If we're currently viewing this USB, update the display. + + Args: + usb_path: The USB mount path + files: List of file dicts from the USB + """ + current = self._curr_dir.removeprefix("/") + + # If we're currently in this USB folder, update the file list + if current == usb_path: + self._file_list = files.copy() + if self.isVisible(): + self._build_file_list() + logger.debug(f"Updated view with preloaded USB files: {usb_path}") def _find_file_insert_position(self, modified_time: float) -> int: """ @@ -213,7 +258,6 @@ def _find_file_insert_position(self, modified_time: float) -> int: continue # This is a file - check its modification time - # Get the filename from display name file_key = self._find_file_key_by_display_name(item.text) if file_key: file_data = self._files_data.get(file_key, {}) @@ -240,9 +284,10 @@ def on_file_added(self, file_data: dict) -> None: Handle a single file being added. Called when Moonraker sends notify_filelist_changed with create_file action. + Adds file to list immediately, metadata updates later. """ path = file_data.get("path", file_data.get("filename", "")) - if not path or not path.lower().endswith(self.GCODE_EXTENSION): + if not path: return # Normalize paths @@ -252,17 +297,49 @@ def on_file_added(self, file_data: dict) -> None: # Check if file belongs to current directory if file_dir != current: + logger.debug( + f"File '{path}' (dir: '{file_dir}') not in current directory ('{current}'), skipping" + ) return # Only update UI if visible if not self.isVisible(): + logger.debug("Widget not visible, will refresh on show") return - # Request metadata - the file will be added when on_fileinfo is called - if path not in self._pending_metadata_requests: - self._pending_metadata_requests.add(path) - self.request_file_info.emit(path) - self.request_file_metadata.emit(path) + display_name = self._get_display_name(path) + + if not self._model_contains_item(display_name): + # Create basic item with unknown info + modified = file_data.get("modified", 0) + + item = ListItem( + text=display_name, + right_text="Unknown Filament - Unknown time", + right_icon=self._icons.get("right_arrow"), + left_icon=None, + callback=None, + selected=False, + allow_check=False, + _lfontsize=self.LEFT_FONT_SIZE, + _rfontsize=self.RIGHT_FONT_SIZE, + height=self.ITEM_HEIGHT, + notificate=False, + ) + + # Find correct position + insert_position = self._find_file_insert_position(modified) + self._model.insert_item(insert_position, item) + self._setup_scrollbar() + self._hide_placeholder() + logger.debug(f"Added new file to list: {display_name}") + + # Request metadata for gcode files using retry mechanism + if path.lower().endswith(self.GCODE_EXTENSION): + if path not in self._pending_metadata_requests: + self._pending_metadata_requests.add(path) + self.retry_metadata_request(path) + logger.debug(f"Requested metadata for new file: {path}") @QtCore.pyqtSlot(str, name="on_file_removed") def on_file_removed(self, filepath: str) -> None: @@ -285,6 +362,9 @@ def on_file_removed(self, filepath: str) -> None: return if file_dir != current: + logger.debug( + f"Deleted file '{filepath}' not in current directory ('{current}'), skipping UI update" + ) return filename = self._get_basename(filepath) @@ -296,6 +376,7 @@ def on_file_removed(self, filepath: str) -> None: if removed: self._setup_scrollbar() self._check_empty_state() + logger.debug(f"File removed from view: {filepath}") @QtCore.pyqtSlot(dict, name="on_file_modified") def on_file_modified(self, file_data: dict) -> None: @@ -328,16 +409,20 @@ def on_dir_added(self, dir_data: dict) -> None: if not dirname or dirname.startswith("."): return - # Determine parent directory path = path.removeprefix("/") parent_dir = self._get_parent_directory(path) if path else "" current = self._curr_dir.removeprefix("/") if parent_dir != current: + logger.debug( + f"Directory '{dirname}' (parent: '{parent_dir}') not in current directory ('{current}'), skipping" + ) return - # Skip UI update if not visible if not self.isVisible(): + logger.debug( + f"Widget not visible, skipping UI update for added dir: {dirname}" + ) return # Check if already exists @@ -373,6 +458,9 @@ def on_dir_added(self, dir_data: dict) -> None: self._setup_scrollbar() self._hide_placeholder() + logger.debug( + f"Directory added to view at position {insert_position}: {dirname}" + ) def _find_directory_insert_position(self, new_dirname: str) -> int: """ @@ -432,23 +520,28 @@ def on_dir_removed(self, dirname_or_path: str) -> None: else dirname_or_path ) - if not dirname or not self.isVisible(): + if not dirname: return # Check if user is currently inside the removed directory (e.g., USB removed) current = self._curr_dir.removeprefix("/") if current == dirname or current.startswith(dirname + "/"): logger.warning( - "Current directory '%s' was removed, returning to root", current + f"Current directory '{current}' was removed, returning to root" ) self.on_directory_error() - self.back_btn.click() return + + # Skip UI update if not visible + if not self.isVisible(): + return + removed = self._model.remove_item_by_text(dirname) if removed: self._setup_scrollbar() self._check_empty_state() + logger.debug(f"Directory removed from view: {dirname}") @QtCore.pyqtSlot(name="on_full_refresh_needed") def on_full_refresh_needed(self) -> None: @@ -469,7 +562,7 @@ def on_directory_error(self) -> None: Immediately navigates back to root gcodes folder. Call this from MainWindow when detecting USB removal or directory errors. """ - logger.error("Directory Error - returning to root directory") + logger.info("Directory Error - returning to root directory") # Reset to root directory self._curr_dir = "" @@ -523,6 +616,7 @@ def _build_file_list(self) -> None: self._entry_delegate.clear() self._pending_action = False self._pending_metadata_requests.clear() + self._metadata_retry_count.clear() # Determine if we're in root directory is_root = not self._curr_dir or self._curr_dir == "/" @@ -549,12 +643,62 @@ def _build_file_list(self) -> None: if dirname and not dirname.startswith("."): self._add_directory_list_item(dir_data) - # Add files (sorted by modification time, newest first) + # Add files immediately (sorted by modification time, newest first) sorted_files = sorted( self._file_list, key=lambda x: x.get("modified", 0), reverse=True ) for file_item in sorted_files: - self._request_file_info(file_item) + filename = file_item.get("filename", file_item.get("path", "")) + if not filename: + continue + + # Add file to list immediately with basic info + self._add_file_to_list(file_item) + + # Request metadata for gcode files (will update display later) + if filename.lower().endswith(self.GCODE_EXTENSION): + self._request_file_info(file_item) + + self._setup_scrollbar() + self._list_widget.blockSignals(False) + self._list_widget.update() + + def _add_file_to_list(self, file_item: dict) -> None: + """Add a file entry to the list with basic info.""" + filename = file_item.get("filename", file_item.get("path", "")) + if not filename or not filename.lower().endswith(self.GCODE_EXTENSION): + return + + # Get display name + display_name = self._get_display_name(filename) + + # Check if already in list + if self._model_contains_item(display_name): + return + + # Use cached metadata if available, otherwise show unknown + full_path = self._build_filepath(filename) + cached = self._files_data.get(full_path) + + if cached: + item = self._create_file_list_item(cached) + else: + item = ListItem( + text=display_name, + right_text="Unknown Filament - Unknown time", + right_icon=self._icons.get("right_arrow"), + left_icon=None, + callback=None, + selected=False, + allow_check=False, + _lfontsize=self.LEFT_FONT_SIZE, + _rfontsize=self.RIGHT_FONT_SIZE, + height=self.ITEM_HEIGHT, + notificate=False, + ) + + if item: + self._model.add_item(item) self._setup_scrollbar() self._list_widget.blockSignals(False) @@ -573,10 +717,26 @@ def _create_file_list_item(self, filedata: dict) -> typing.Optional[ListItem]: # Get filament type filament_type = filedata.get("filament_type") + if isinstance(filament_type, str): + text = filament_type.strip() + if text.startswith("[") and text.endswith("]"): + try: + types = json.loads(text) + except json.JSONDecodeError: + types = [text] + else: + types = [text] + else: + types = filament_type or [] + + if not isinstance(types, list): + types = [types] + + filament_type = ",".join(dict.fromkeys(types)) + if not filament_type or filament_type == -1.0 or filament_type == "Unknown": - filament_type = "Unknown filament" + filament_type = "Unknown Filament" - # Get display name (without path and .gcode extension) display_name = self._get_display_name(filename) return ListItem( @@ -636,7 +796,7 @@ def _add_back_folder_entry(self) -> None: self._model.add_item(item) def _request_file_info(self, file_data_item: dict) -> None: - """Request metadata for a file item.""" + """Request metadata for a file item using retry mechanism.""" if not file_data_item: return @@ -650,8 +810,8 @@ def _request_file_info(self, file_data_item: dict) -> None: # Track pending request self._pending_metadata_requests.add(file_path) - self.request_file_info.emit(file_path) - self.request_file_metadata.emit(file_path) + # Use retry mechanism (first attempt uses get_gcode_metadata) + self.retry_metadata_request(file_path) def _on_file_item_clicked(self, filename: str) -> None: """Handle file item click.""" @@ -766,9 +926,9 @@ def _is_usb_directory(parent_dir: str, directory_name: str) -> bool: def _format_print_time(seconds: int) -> str: """Format print time in human-readable form.""" if seconds <= 0: - return "??" + return "Unknown time" if seconds < 60: - return "less than 1 minute" + return f"{seconds}m" days, hours, minutes, _ = helper_methods.estimate_print_time(seconds)