diff --git a/BlocksScreen/lib/files.py b/BlocksScreen/lib/files.py index 0eda561d..f40ecec8 100644 --- a/BlocksScreen/lib/files.py +++ b/BlocksScreen/lib/files.py @@ -1,180 +1,694 @@ -# -# 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. + + Thumbnails are stored as QImage objects when available. + """ + + 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. + 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 + 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") - def __init__( - self, - parent: QtCore.QObject, - ws: MoonWebSocket, - update_interval: int = 5000, - ) -> None: - super(Files, self).__init__(parent) + # 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 + 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") + + # 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" + + def __init__(self, parent: QtCore.QObject, ws: MoonWebSocket) -> None: + super().__init__(parent) self.ws = ws - 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) + + # 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(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() + self._install_event_filter() + + 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(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) - QtWidgets.QApplication.instance().installEventFilter(self) # type: ignore + self.request_dir_info[str].connect(self.ws.api.get_dir_information) + self.request_file_metadata.connect(self.ws.api.get_gcode_metadata) + + 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): - """Available files list""" - return self.files + def file_list(self) -> list[dict]: + """Get list of files in current directory.""" + return list(self._files.values()) + + @property + def directories(self) -> list[dict]: + """Get list of directories in current directory.""" + return list(self._directories.values()) + + @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.removeprefix("/")) + + 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.""" + logger.debug(f"Refreshing directory: {directory or 'root'}") + self._current_directory = directory + self.request_dir_info[str, bool].emit(directory, True) - def handle_message_received(self, method: str, data, params: dict) -> None: - """Handle file related messages received by moonraker""" + def initial_load(self) -> None: + """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.""" + if isinstance(data, dict) and "params" in data: + data = data.get("params", []) + + if isinstance(data, list): + if len(data) > 0: + data = data[0] + else: + return + + if not isinstance(data, dict): + return + + action_str = data.get("action", "") + action = FileAction.from_string(action_str) + item = data.get("item", {}) + source_item = data.get("source_item", {}) + + logger.debug(f"File list changed: action={action_str}, item={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) + + def _handle_file_created(self, item: dict, _: dict) -> None: + """Handle new file creation.""" + path = item.get("path", "") + if not path: + return + + if self._is_usb_mount(path): + item["dirname"] = path + self._handle_dir_created(item, {}) + return + + if not path.lower().endswith(self.GCODE_EXTENSION): + return + + self._files[path] = item + self.file_added.emit(item) + + # 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.""" + path = item.get("path", "") + if not path: + return + + if self._is_usb_mount(path): + item["dirname"] = path + self._handle_dir_deleted(item, {}) + return + + self._files.pop(path, None) + self._files_metadata.pop(path.removeprefix("/"), None) + + self.file_removed.emit(path) + logger.info(f"File deleted: {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 + + self._files[path] = item + self._files_metadata.pop(path.removeprefix("/"), None) + + self.request_file_metadata.emit(path.removeprefix("/")) + self.file_modified.emit(item) + 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", "") + + if old_path: + self._handle_file_deleted(source_item, {}) + if new_path: + self._handle_file_created(item, {}) + + def _handle_dir_created(self, item: dict, _: dict) -> None: + """Handle directory creation.""" + path = item.get("path", "") + dirname = item.get("dirname", "") + + if not dirname and path: + dirname = path.rstrip("/").split("/")[-1] + + if not dirname or dirname.startswith("."): + return + + item["dirname"] = dirname + self._directories[dirname] = item + self.dir_added.emit(item) + 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", "") + + if not dirname and path: + dirname = path.rstrip("/").split("/")[-1] + + if not dirname: + return + + self._directories.pop(dirname, None) + + # 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(f"Directory deleted: {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.""" + 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.""" + path = path.removeprefix("/") + 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) - @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 + 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 + + self._initial_load_complete = True + self.on_file_list.emit(self.file_list) + 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.""" + filename = data.get("filename") + if not filename: + return + + 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 + + # 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: - filename (str): file to delete - directory (str): root directory where the file is located + error_data: The error message string or dict from Moonraker """ - if not directory: - self.ws.api.delete_file(filename) + if not error_data: return - self.ws.api.delete_file(filename, directory) # Use the root directory 'gcodes' - @QtCore.pyqtSlot(str, name="on_request_fileinfo") - def on_request_fileinfo(self, filename: str) -> None: - """Requests metadata for a file + 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: - filename (str): file to get metadata from + usb_path: The USB mount path (e.g., "USB-sda1") """ - _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)} + 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() + + for dir_data in data.get("dirs", []): + dirname = dir_data.get("dirname", "") + if dirname and not dirname.startswith("."): + self._directories[dirname] = dir_data + + for file_data in data.get("files", []): + filename = file_data.get("filename", file_data.get("path", "")) + if filename: + self._files[filename] = file_data + + self.on_file_list.emit(self.file_list) + self.on_dirs.emit(self.directories) + self._initial_load_complete = True + + logger.info( + f"Directory loaded: {len(self._directories)} dirs, {len(self._files)} files" ) - _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) + + # 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: + return + + if directory: + self.ws.api.delete_file(filename, directory) + else: + self.ws.api.delete_file(filename) + + logger.info(f"Requested deletion of: {filename}") + + @QtCore.pyqtSlot(str, name="on_request_fileinfo") + def on_request_fileinfo(self, filename: str) -> None: + """Request and emit metadata for a file.""" + clean_filename = filename.removeprefix("/") + cached = self._files_metadata.get(clean_filename) + + 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.""" + self._current_directory = directory + + if not extended and self._initial_load_complete: + 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._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 32355803..afd6948e 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,7 @@ 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""" - ... + self.file_data.handle_filelist_changed(data) @api_handler def _handle_notify_service_state_changed_message( @@ -617,24 +617,34 @@ 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 "metadata" in data.get("message", "").lower(): - # Quick fix, don't care about no metadata errors - return 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: + self.file_data.handle_metadata_error(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) @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..d1b94b1a 100644 --- a/BlocksScreen/lib/panels/printTab.py +++ b/BlocksScreen/lib/panels/printTab.py @@ -127,8 +127,23 @@ 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) + 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.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 f8fa490f..63ce0aa6 100644 --- a/BlocksScreen/lib/panels/widgets/filesPage.py +++ b/BlocksScreen/lib/panels/widgets/filesPage.py @@ -1,411 +1,1135 @@ +import json 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") +logger = logging.getLogger(__name__) 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" - ) - request_file_metadata: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( - str, name="api-get-gcode-metadata" + """ + 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" ) - 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") + request_scan_metadata = QtCore.pyqtSignal(str, name="api_scan_gcode_metadata") + + # Constants + GCODE_EXTENSION = ".gcode" + USB_PREFIX = "USB-" + ITEM_HEIGHT = 80 + LEFT_FONT_SIZE = 17 + RIGHT_FONT_SIZE = 12 + + # Icon paths + 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) + + 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 (max 3) + self._icons: dict[str, QtGui.QPixmap] = {} + + self._model = EntryListModel() + self._entry_delegate = EntryDelegate() + + self._setup_ui() + self._load_icons() + self._connect_signals() + 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 - @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 + @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.""" + logger.debug("Reloading gcodes folder") + 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 retry_metadata_request(self, file_path: str) -> bool: + """ + Request metadata with a maximum of 3 retries per file. Args: - item : ListItem The item that was selected by the user. + file_path: Path to the file + Returns: + True if request was made, False if max retries 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) + 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: + # Maximum 3 force scan per file + logger.debug(f"Metadata retry limit reached for: {clean_path}") + return False - def showEvent(self, a0: QtGui.QShowEvent) -> None: - """Re-implemented method, handle widget show event""" - self._build_file_list() - return super().showEvent(a0) + 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}") - @QtCore.pyqtSlot(list, name="on-file-list") + return True + + @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 [] + logger.debug(f"Received file list with {len(self._file_list)} files") - @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 [] + logger.debug(f"Received {len(self._directories)} directories") + 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. + + 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 or not filename.lower().endswith(self.GCODE_EXTENSION): + return + + # Cache the file data + self._files_data[filename] = filedata + + # 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) + current = self._curr_dir.removeprefix("/") + + # Both empty = root directory, otherwise must match exactly + if file_dir != current: + return + + display_name = self._get_display_name(filename) + item = self._create_file_list_item(filedata) + 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 - 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) + 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: + """ + 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 + 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. + Adds file to list immediately, metadata updates later. + """ + path = file_data.get("path", file_data.get("filename", "")) + if not path: + 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: + 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 + + 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: + """ + 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: + logger.debug( + f"Deleted file '{filepath}' not in current directory ('{current}'), skipping UI update" + ) + 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() + logger.debug(f"File removed from view: {filepath}") + + @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 + + 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 + + if not self.isVisible(): + logger.debug( + f"Widget not visible, skipping UI update for added dir: {dirname}" + ) + 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() + logger.debug( + f"Directory added to view at position {insert_position}: {dirname}" + ) - 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: """ - self.file_selected.emit( - str(filename.removeprefix("/")), - self.files_data.get(filename.removeprefix("/")), + 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). + """ + 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: + 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( + f"Current directory '{current}' was removed, returning to root" + ) + self.on_directory_error() + 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: + """ + 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.info("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() + self._metadata_retry_count.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 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: + 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.listWidget.blockSignals(False) - self.listWidget.update() + 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) + 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 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" + + 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) - - @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 + self._model.add_item(item) - 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 using retry mechanism.""" 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) + # 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.""" + 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 "Unknown time" + if seconds < 60: + return f"{seconds}m" + + 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 - ) - 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") + 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.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()) - ) + scroller.setScrollerProperties(props) - self.scrollbar = CustomScrollBar() - - 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()]