diff --git a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/autodesk.py b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/autodesk.py index 439d749ecd..f4bd421fcf 100644 --- a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/autodesk.py +++ b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/autodesk.py @@ -250,7 +250,7 @@ class ApsClient: def list_hubs(self) -> list[dict[str, Any]]: payload = self._get_json("https://developer.api.autodesk.com/project/v1/hubs") - return [ + hubs = [ { "id": item["id"], "name": item["attributes"]["name"], @@ -258,6 +258,8 @@ class ApsClient: } for item in payload.get("data", []) ] + hubs.sort(key=lambda h: (h["name"] or "").casefold()) + return hubs def list_projects(self, hub_id: str) -> list[dict[str, Any]]: url = f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects" @@ -274,13 +276,16 @@ class ApsClient: } ) url = payload.get("links", {}).get("next", {}).get("href", "") or "" + projects.sort(key=lambda p: (p["name"] or "").casefold()) return projects def list_top_folders(self, hub_id: str, project_id: str) -> list[dict[str, Any]]: payload = self._get_json( f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects/{project_id}/topFolders" ) - return [self._entry(item) for item in payload.get("data", [])] + folders = [self._entry(item) for item in payload.get("data", [])] + folders.sort(key=lambda e: (e.get("display_name") or "").casefold()) + return folders def list_folder_contents( self, @@ -303,6 +308,7 @@ class ApsClient: continue entries.append(entry) url = payload.get("links", {}).get("next", {}).get("href", "") or "" + entries.sort(key=lambda e: (e.get("display_name") or "").casefold()) return entries def get_item(self, project_id: str, item_id: str) -> dict[str, Any]: @@ -364,7 +370,7 @@ class ApsClient: storage_id: str, destination_path: Path, *, - progress: Callable[[str, int | None], None] | None = None, + progress: Callable[[str, int | None, int | None, int | None], None] | None = None, ) -> None: bucket_key, object_key = self._parse_storage_id(storage_id) signed_url = self._get_signed_download_url(bucket_key, object_key) @@ -377,7 +383,7 @@ class ApsClient: local_path: Path, *, display_name: str | None = None, - progress: Callable[[str, int | None], None] | None = None, + progress: Callable[[str, int | None, int | None, int | None], None] | None = None, ) -> dict[str, Any]: if not local_path.exists(): raise RpcError(JSONRPC_INTERNAL_ERROR, f"Local file '{local_path}' does not exist.") @@ -439,12 +445,12 @@ class ApsClient: self, url: str, destination_path: Path, - progress: Callable[[str, int | None], None] | None, + progress: Callable[[str, int | None, int | None, int | None], None] | None, ) -> None: try: with self.http.stream("GET", url) as response: response.raise_for_status() - total_bytes = None + total_bytes: int | None = None header_value = response.headers.get("Content-Length") if header_value and header_value.isdigit(): total_bytes = int(header_value) @@ -455,9 +461,9 @@ class ApsClient: downloaded_bytes += len(chunk) if progress and total_bytes: percent = min(100, int((downloaded_bytes / total_bytes) * 100)) - progress(destination_path.name, percent) + progress(destination_path.name, percent, downloaded_bytes, total_bytes) elif progress: - progress(destination_path.name, None) + progress(destination_path.name, None, downloaded_bytes, total_bytes) except httpx.HTTPStatusError as exc: body = exc.response.text.strip() raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc @@ -486,7 +492,7 @@ class ApsClient: bucket_key: str, object_key: str, local_path: Path, - progress: Callable[[str, int | None], None] | None, + progress: Callable[[str, int | None, int | None, int | None], None] | None, ) -> None: file_size = local_path.stat().st_size chunk_size = 5 * 1024 * 1024 @@ -522,7 +528,7 @@ class ApsClient: bytes_uploaded += len(chunk) if progress: percent = 100 if file_size == 0 else min(100, int((bytes_uploaded / file_size) * 100)) - progress(local_path.name, percent) + progress(local_path.name, percent, bytes_uploaded, file_size) if not upload_key: raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload did not return an upload key.") diff --git a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/connector.py b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/connector.py index 4181ea8d1f..8fd5860cef 100644 --- a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/connector.py +++ b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/connector.py @@ -11,25 +11,51 @@ from bonsaiviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, JSONRPC_INVALID_PA from bonsaiviewer_autodesk.ui import BrowseDialog, SettingsDialog, progress_dialog, prompt_for_filename -ApsProgress = Callable[[str, "int | None"], None] -Report = Callable[[str, str, "int | None"], None] +ApsProgress = Callable[[str, "int | None", "int | None", "int | None"], None] +Report = Callable[[str, str, "int | None", "str | None"], None] + + +def _format_bytes(value: int) -> str: + """Render a byte count as a short human-readable string (e.g. '3.4 MB').""" + if value < 1024: + return f"{value} B" + scaled = float(value) + for unit in ("KB", "MB", "GB", "TB"): + scaled /= 1024.0 + if scaled < 1024 or unit == "TB": + return f"{scaled:.1f} {unit}" + return f"{value} B" + + +def _progress_detail(percent: int | None, done: int | None, total: int | None) -> str: + """Build the stats line shown beneath the filename, e.g. '45%, 4.5 MB / 10.0 MB'.""" + parts: list[str] = [] + if percent is not None: + parts.append(f"{percent}%") + if done is not None and total: + parts.append(f"{_format_bytes(done)} / {_format_bytes(total)}") + elif done is not None: + parts.append(_format_bytes(done)) + return ", ".join(parts) def _download_callback(report: Report, index: int = 0, total: int = 0) -> ApsProgress: - """Adapt ProgressDialog.report (3-arg) to the APS download callback (2-arg). + """Adapt ProgressDialog.report to the APS download callback. index/total render "(i/N)" suffix when batching; pass 0 (the default) for single-file downloads to omit the suffix. """ - def cb(name: str, percent: int | None) -> None: + def cb(name: str, percent: int | None, bytes_done: int | None, bytes_total: int | None) -> None: suffix = f" ({index}/{total})" if total else "" - report("download", f"Downloading {name}{suffix}", percent) + detail = _progress_detail(percent, bytes_done, bytes_total) + report("download", f"Downloading {name}{suffix}", percent, detail) return cb def _upload_callback(report: Report) -> ApsProgress: - def cb(name: str, percent: int | None) -> None: - report("upload", f"Uploading {name}", percent) + def cb(name: str, percent: int | None, bytes_done: int | None, bytes_total: int | None) -> None: + detail = _progress_detail(percent, bytes_done, bytes_total) + report("upload", f"Uploading {name}", percent, detail) return cb @@ -95,7 +121,7 @@ class AutodeskConnector: chosen = BrowseDialog(auth=auth, aps=aps, mode="ifcfed").run() hub = chosen["hub"] project = chosen["project"] - entry = chosen["entry"] + entry = chosen["entries"][0] with progress_dialog("Downloading project") as report: path = self._download_ifcfed( aps=aps, @@ -230,10 +256,37 @@ class AutodeskConnector: chosen = BrowseDialog(auth=auth, aps=aps, mode="model").run() hub = chosen["hub"] project = chosen["project"] - entry = chosen["entry"] + entries = chosen["entries"] + + results: list[dict[str, Any]] = [] + total = len(entries) + with progress_dialog("Downloading models") as report: + for index, entry in enumerate(entries): + callback = _download_callback(report, index=index + 1, total=total) + try: + result = self._download_picked_model(aps, hub, project, entry, callback) + except RpcError as exc: + print(f"pull_models_interactive[{index}] skipped: {exc.message}", file=sys.stderr) + continue + except Exception as exc: + print(f"pull_models_interactive[{index}] skipped: {exc}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + continue + if result is not None: + results.append(result) + return results + + def _download_picked_model( + self, + aps: ApsClient, + hub: dict[str, Any], + project: dict[str, Any], + entry: dict[str, Any], + progress: ApsProgress, + ) -> dict[str, Any] | None: item = aps.get_item(project["id"], entry["id"]) if item["hidden"]: - raise RpcError(JSONRPC_INTERNAL_ERROR, "The selected Autodesk item has been deleted.") + raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{entry['id']}' has been deleted.") storage_id = item["storage_id"] if not isinstance(storage_id, str): raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{entry['id']}' has no downloadable storage.") @@ -245,24 +298,19 @@ class AutodeskConnector: model_path = directory / file_name if not model_path.exists(): cache.prepare_sole_child_dir(directory) - with progress_dialog("Downloading model") as report: - aps.download_storage_to_file( - storage_id, model_path, progress=_download_callback(report) - ) + aps.download_storage_to_file(storage_id, model_path, progress=progress) - return [ - { - "display_name": file_name, - "source": { - "connector": CONNECTOR_ID, - "hub_id": hub["id"], - "project_id": project["id"], - "item_id": entry["id"], - }, - "path": str(model_path), - "metadata": _build_metadata(item), - } - ] + return { + "display_name": file_name, + "source": { + "connector": CONNECTOR_ID, + "hub_id": hub["id"], + "project_id": project["id"], + "item_id": entry["id"], + }, + "path": str(model_path), + "metadata": _build_metadata(item), + } # ---- push_ifcfed_interactive -------------------------------------------- @@ -278,7 +326,7 @@ class AutodeskConnector: chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run() hub = chosen["hub"] project = chosen["project"] - folder = chosen["entry"] + folder = chosen["entries"][0] file_name = prompt_for_filename( title="Save Project", @@ -378,7 +426,7 @@ class AutodeskConnector: chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run() hub = chosen["hub"] project = chosen["project"] - folder = chosen["entry"] + folder = chosen["entries"][0] file_name = prompt_for_filename( title="Save Model", diff --git a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/ui.py b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/ui.py index 7fa1a02586..c3d3382a33 100644 --- a/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/ui.py +++ b/src/bonsaiviewer-autodesk/bonsaiviewer_autodesk/ui.py @@ -110,28 +110,57 @@ class _BaseDialog(ctk.CTkToplevel): class ProgressDialog(_BaseDialog): + """Fixed-size progress dialog: a title line, a stats line, and a bar. + + Both text lines are single-line and middle-elided with '…' so a long + filename can never reflow the layout or resize the window. + """ + + _WIDTH = 560 + def __init__(self, title: str = "Working", parent: tk.Misc | None = None) -> None: - super().__init__(title, size=(440, 130), resizable=False) + super().__init__(title, size=(self._WIDTH, 160), resizable=False) + + self._text_font = ctk.CTkFont() body = ctk.CTkFrame(self) body.pack(fill="both", expand=True, padx=20, pady=20) - self.message = ctk.CTkLabel(body, text="Working…", anchor="w") - self.message.pack(fill="x", anchor="w") + self.title_label = ctk.CTkLabel(body, text="Working…", anchor="w", font=self._text_font) + self.title_label.pack(fill="x", padx=16, pady=(16, 0)) + + # Initialised with a space so the line reserves its height before the + # first report(); a single-line label never grows taller than this. + self.detail_label = ctk.CTkLabel(body, text=" ", anchor="w", font=self._text_font) + self.detail_label.pack(fill="x", padx=16, pady=(2, 0)) self.bar = ctk.CTkProgressBar(body, mode="indeterminate") - self.bar.pack(fill="x", pady=(12, 0)) + self.bar.pack(fill="x", padx=16, pady=(14, 16)) self.bar.start() self._determinate = False + # Derive the height from the laid-out content (font-driven) rather than + # hardcoding it, then lock it. Single-line labels keep it stable no + # matter how long the text is. + self.update_idletasks() + self.geometry(f"{self._WIDTH}x{self.winfo_reqheight()}") + self._center_on_screen() self.deiconify() self.lift() self.update() - def report(self, _phase: str, message: str, percent: int | None = None) -> None: + def report( + self, + _phase: str, + message: str, + percent: int | None = None, + detail: str | None = None, + ) -> None: try: - self.message.configure(text=message) + width = self._text_area_width() + self.title_label.configure(text=self._elide_middle(message, width)) + self.detail_label.configure(text=self._elide_middle(detail or " ", width)) if percent is None: if self._determinate: self.bar.configure(mode="indeterminate") @@ -147,6 +176,29 @@ class ProgressDialog(_BaseDialog): except tk.TclError: pass + def _text_area_width(self) -> int: + """Pixels available for label text, in the same scaled space as the font.""" + width = self.title_label.winfo_width() + if width <= 1: # not laid out yet + return self._WIDTH - 2 * 20 - 2 * 16 + return max(40, width - 6) + + def _elide_middle(self, text: str, max_width: int) -> str: + """Middle-truncate text with '…' so it fits max_width without wrapping.""" + font = self._text_font + if font.measure(text) <= max_width: + return text + ellipsis = "…" + keep = len(text) - 1 + while keep > 0: + head = (keep + 1) // 2 + tail = keep - head + candidate = text[:head] + ellipsis + (text[-tail:] if tail else "") + if font.measure(candidate) <= max_width: + return candidate + keep -= 1 + return ellipsis + class _ProgressContext: def __init__(self, parent: tk.Misc | None, message: str) -> None: @@ -154,7 +206,7 @@ class _ProgressContext: self.message = message self.dialog: ProgressDialog | None = None - def __enter__(self) -> Callable[[str, str, int | None], None]: + def __enter__(self) -> Callable[..., None]: self.dialog = ProgressDialog(self.message, self.parent) return self.dialog.report @@ -197,9 +249,10 @@ class BrowseDialog(_BaseDialog): self.auth = auth self.aps = aps self.mode: Mode = mode + self.multi_select = mode == "model" self.selected_hub: dict[str, Any] | None = None self.selected_project: dict[str, Any] | None = None - self.selected_entry: dict[str, Any] | None = None + self.selected_entries: list[dict[str, Any]] = [] self._tree_entries: dict[str, dict[str, Any]] = {} self._project_entries: dict[str, dict[str, Any]] = {} @@ -241,7 +294,8 @@ class BrowseDialog(_BaseDialog): self.projects = self._make_treeview(self.projects_frame, "PROJECTS") self.projects.bind("<>", lambda _e: self._project_changed()) - self.tree = self._make_treeview(self.tree_frame, "FOLDERS") + tree_selectmode = "extended" if self.multi_select else "browse" + self.tree = self._make_treeview(self.tree_frame, "FOLDERS", selectmode=tree_selectmode) self.tree.bind("<>", lambda _e: self._tree_selection_changed()) self.tree.bind("<>", self._on_tree_open) @@ -257,14 +311,14 @@ class BrowseDialog(_BaseDialog): self.action_button.grid(row=0, column=2) self.action_button.configure(state="disabled") - def _make_treeview(self, parent: ctk.CTkFrame, header: str) -> ttk.Treeview: + def _make_treeview(self, parent: ctk.CTkFrame, header: str, selectmode: str = "browse") -> ttk.Treeview: ctk.CTkLabel(parent, text=header, anchor="w").pack(fill="x", padx=12, pady=(8, 0)) body = ctk.CTkFrame(parent, fg_color="transparent") body.pack(fill="both", expand=True, padx=8, pady=8) body.grid_rowconfigure(0, weight=1) body.grid_columnconfigure(0, weight=1) - tree = ttk.Treeview(body, show="tree", selectmode="browse") + tree = ttk.Treeview(body, show="tree", selectmode=selectmode) tree.grid(row=0, column=0, sticky="nsew") scrollbar = ctk.CTkScrollbar(body, orientation="vertical", command=tree.yview) scrollbar.grid(row=0, column=1, sticky="ns") @@ -336,7 +390,7 @@ class BrowseDialog(_BaseDialog): if not isinstance(project, dict): return self.selected_project = project - self.selected_entry = None + self.selected_entries = [] self._refresh_action_button() self._clear_tree() with self._with_progress("Loading top folders"): @@ -400,41 +454,53 @@ class BrowseDialog(_BaseDialog): def _tree_selection_changed(self) -> None: selection = self.tree.selection() - entry = self._tree_entries.get(selection[0]) if selection else None - if isinstance(entry, dict) and not entry.get("__placeholder__"): - self.selected_entry = entry + entries: list[dict[str, Any]] = [] + for iid in selection: + entry = self._tree_entries.get(iid) + if isinstance(entry, dict) and not entry.get("__placeholder__"): + entries.append(entry) + self.selected_entries = entries + + if not entries: + pass + elif len(entries) == 1: + entry = entries[0] name = entry.get("display_name") or entry.get("name") or entry.get("id", "?") kind = entry.get("type", "entry") self.status.configure(text=f"Selected {kind}: {name}") else: - self.selected_entry = None + valid_count = sum(1 for e in entries if self._is_valid_selection(e)) + self.status.configure(text=f"Selected {valid_count} of {len(entries)} items.") self._refresh_action_button() + def _is_valid_selection(self, entry: dict[str, Any]) -> bool: + if self.mode == "destination": + return entry.get("type") == "folders" + if self.mode == "ifcfed": + return ( + entry.get("type") == "items" + and (entry.get("display_name") or "").lower().endswith(".ifcfed") + ) + return ( + entry.get("type") == "items" + and (entry.get("display_name") or "").lower().endswith(MODEL_EXTENSIONS) + ) + + def _valid_entries(self) -> list[dict[str, Any]]: + return [e for e in self.selected_entries if self._is_valid_selection(e)] + def _refresh_action_button(self) -> None: - enabled = self.selected_project is not None and self.selected_entry is not None - if enabled: - assert self.selected_entry is not None - if self.mode == "destination": - enabled = self.selected_entry.get("type") == "folders" - elif self.mode == "ifcfed": - enabled = ( - self.selected_entry.get("type") == "items" - and (self.selected_entry.get("display_name") or "").lower().endswith(".ifcfed") - ) - else: - enabled = ( - self.selected_entry.get("type") == "items" - and (self.selected_entry.get("display_name") or "").lower().endswith(MODEL_EXTENSIONS) - ) + enabled = self.selected_project is not None and bool(self._valid_entries()) self.action_button.configure(state="normal" if enabled else "disabled") def _confirm(self) -> None: - if not self.selected_hub or not self.selected_project or not self.selected_entry: + valid = self._valid_entries() + if not self.selected_hub or not self.selected_project or not valid: return self.result = { "hub": self.selected_hub, "project": self.selected_project, - "entry": self.selected_entry, + "entries": valid, } self._on_close()