mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-22 19:52:36 +00:00
Improve Autodesk connector browsing and progress UI
Sort hubs, projects, folders and files alphabetically. Allow multi-select when adding models so several can be pulled at once. Rework the progress dialog into a fixed-shape two-line layout that shows percent and byte counts, middle-eliding long filenames so the window never reflows. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -250,7 +250,7 @@ class ApsClient:
|
|||||||
|
|
||||||
def list_hubs(self) -> list[dict[str, Any]]:
|
def list_hubs(self) -> list[dict[str, Any]]:
|
||||||
payload = self._get_json("https://developer.api.autodesk.com/project/v1/hubs")
|
payload = self._get_json("https://developer.api.autodesk.com/project/v1/hubs")
|
||||||
return [
|
hubs = [
|
||||||
{
|
{
|
||||||
"id": item["id"],
|
"id": item["id"],
|
||||||
"name": item["attributes"]["name"],
|
"name": item["attributes"]["name"],
|
||||||
@@ -258,6 +258,8 @@ class ApsClient:
|
|||||||
}
|
}
|
||||||
for item in payload.get("data", [])
|
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]]:
|
def list_projects(self, hub_id: str) -> list[dict[str, Any]]:
|
||||||
url = f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects"
|
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 ""
|
url = payload.get("links", {}).get("next", {}).get("href", "") or ""
|
||||||
|
projects.sort(key=lambda p: (p["name"] or "").casefold())
|
||||||
return projects
|
return projects
|
||||||
|
|
||||||
def list_top_folders(self, hub_id: str, project_id: str) -> list[dict[str, Any]]:
|
def list_top_folders(self, hub_id: str, project_id: str) -> list[dict[str, Any]]:
|
||||||
payload = self._get_json(
|
payload = self._get_json(
|
||||||
f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects/{project_id}/topFolders"
|
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(
|
def list_folder_contents(
|
||||||
self,
|
self,
|
||||||
@@ -303,6 +308,7 @@ class ApsClient:
|
|||||||
continue
|
continue
|
||||||
entries.append(entry)
|
entries.append(entry)
|
||||||
url = payload.get("links", {}).get("next", {}).get("href", "") or ""
|
url = payload.get("links", {}).get("next", {}).get("href", "") or ""
|
||||||
|
entries.sort(key=lambda e: (e.get("display_name") or "").casefold())
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
def get_item(self, project_id: str, item_id: str) -> dict[str, Any]:
|
def get_item(self, project_id: str, item_id: str) -> dict[str, Any]:
|
||||||
@@ -364,7 +370,7 @@ class ApsClient:
|
|||||||
storage_id: str,
|
storage_id: str,
|
||||||
destination_path: Path,
|
destination_path: Path,
|
||||||
*,
|
*,
|
||||||
progress: Callable[[str, int | None], None] | None = None,
|
progress: Callable[[str, int | None, int | None, int | None], None] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
bucket_key, object_key = self._parse_storage_id(storage_id)
|
bucket_key, object_key = self._parse_storage_id(storage_id)
|
||||||
signed_url = self._get_signed_download_url(bucket_key, object_key)
|
signed_url = self._get_signed_download_url(bucket_key, object_key)
|
||||||
@@ -377,7 +383,7 @@ class ApsClient:
|
|||||||
local_path: Path,
|
local_path: Path,
|
||||||
*,
|
*,
|
||||||
display_name: str | None = None,
|
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]:
|
) -> dict[str, Any]:
|
||||||
if not local_path.exists():
|
if not local_path.exists():
|
||||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Local file '{local_path}' does not exist.")
|
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Local file '{local_path}' does not exist.")
|
||||||
@@ -439,12 +445,12 @@ class ApsClient:
|
|||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
destination_path: Path,
|
destination_path: Path,
|
||||||
progress: Callable[[str, int | None], None] | None,
|
progress: Callable[[str, int | None, int | None, int | None], None] | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
with self.http.stream("GET", url) as response:
|
with self.http.stream("GET", url) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
total_bytes = None
|
total_bytes: int | None = None
|
||||||
header_value = response.headers.get("Content-Length")
|
header_value = response.headers.get("Content-Length")
|
||||||
if header_value and header_value.isdigit():
|
if header_value and header_value.isdigit():
|
||||||
total_bytes = int(header_value)
|
total_bytes = int(header_value)
|
||||||
@@ -455,9 +461,9 @@ class ApsClient:
|
|||||||
downloaded_bytes += len(chunk)
|
downloaded_bytes += len(chunk)
|
||||||
if progress and total_bytes:
|
if progress and total_bytes:
|
||||||
percent = min(100, int((downloaded_bytes / total_bytes) * 100))
|
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:
|
elif progress:
|
||||||
progress(destination_path.name, None)
|
progress(destination_path.name, None, downloaded_bytes, total_bytes)
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
body = exc.response.text.strip()
|
body = exc.response.text.strip()
|
||||||
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
|
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
|
||||||
@@ -486,7 +492,7 @@ class ApsClient:
|
|||||||
bucket_key: str,
|
bucket_key: str,
|
||||||
object_key: str,
|
object_key: str,
|
||||||
local_path: Path,
|
local_path: Path,
|
||||||
progress: Callable[[str, int | None], None] | None,
|
progress: Callable[[str, int | None, int | None, int | None], None] | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
file_size = local_path.stat().st_size
|
file_size = local_path.stat().st_size
|
||||||
chunk_size = 5 * 1024 * 1024
|
chunk_size = 5 * 1024 * 1024
|
||||||
@@ -522,7 +528,7 @@ class ApsClient:
|
|||||||
bytes_uploaded += len(chunk)
|
bytes_uploaded += len(chunk)
|
||||||
if progress:
|
if progress:
|
||||||
percent = 100 if file_size == 0 else min(100, int((bytes_uploaded / file_size) * 100))
|
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:
|
if not upload_key:
|
||||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload did not return an upload key.")
|
raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload did not return an upload key.")
|
||||||
|
|||||||
@@ -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
|
from bonsaiviewer_autodesk.ui import BrowseDialog, SettingsDialog, progress_dialog, prompt_for_filename
|
||||||
|
|
||||||
|
|
||||||
ApsProgress = Callable[[str, "int | None"], None]
|
ApsProgress = Callable[[str, "int | None", "int | None", "int | None"], None]
|
||||||
Report = Callable[[str, str, "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:
|
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
|
index/total render "(i/N)" suffix when batching; pass 0 (the default) for
|
||||||
single-file downloads to omit the suffix.
|
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 ""
|
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
|
return cb
|
||||||
|
|
||||||
|
|
||||||
def _upload_callback(report: Report) -> ApsProgress:
|
def _upload_callback(report: Report) -> ApsProgress:
|
||||||
def cb(name: str, percent: int | None) -> None:
|
def cb(name: str, percent: int | None, bytes_done: int | None, bytes_total: int | None) -> None:
|
||||||
report("upload", f"Uploading {name}", percent)
|
detail = _progress_detail(percent, bytes_done, bytes_total)
|
||||||
|
report("upload", f"Uploading {name}", percent, detail)
|
||||||
return cb
|
return cb
|
||||||
|
|
||||||
|
|
||||||
@@ -95,7 +121,7 @@ class AutodeskConnector:
|
|||||||
chosen = BrowseDialog(auth=auth, aps=aps, mode="ifcfed").run()
|
chosen = BrowseDialog(auth=auth, aps=aps, mode="ifcfed").run()
|
||||||
hub = chosen["hub"]
|
hub = chosen["hub"]
|
||||||
project = chosen["project"]
|
project = chosen["project"]
|
||||||
entry = chosen["entry"]
|
entry = chosen["entries"][0]
|
||||||
with progress_dialog("Downloading project") as report:
|
with progress_dialog("Downloading project") as report:
|
||||||
path = self._download_ifcfed(
|
path = self._download_ifcfed(
|
||||||
aps=aps,
|
aps=aps,
|
||||||
@@ -230,10 +256,37 @@ class AutodeskConnector:
|
|||||||
chosen = BrowseDialog(auth=auth, aps=aps, mode="model").run()
|
chosen = BrowseDialog(auth=auth, aps=aps, mode="model").run()
|
||||||
hub = chosen["hub"]
|
hub = chosen["hub"]
|
||||||
project = chosen["project"]
|
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"])
|
item = aps.get_item(project["id"], entry["id"])
|
||||||
if item["hidden"]:
|
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"]
|
storage_id = item["storage_id"]
|
||||||
if not isinstance(storage_id, str):
|
if not isinstance(storage_id, str):
|
||||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{entry['id']}' has no downloadable storage.")
|
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
|
model_path = directory / file_name
|
||||||
if not model_path.exists():
|
if not model_path.exists():
|
||||||
cache.prepare_sole_child_dir(directory)
|
cache.prepare_sole_child_dir(directory)
|
||||||
with progress_dialog("Downloading model") as report:
|
aps.download_storage_to_file(storage_id, model_path, progress=progress)
|
||||||
aps.download_storage_to_file(
|
|
||||||
storage_id, model_path, progress=_download_callback(report)
|
|
||||||
)
|
|
||||||
|
|
||||||
return [
|
return {
|
||||||
{
|
"display_name": file_name,
|
||||||
"display_name": file_name,
|
"source": {
|
||||||
"source": {
|
"connector": CONNECTOR_ID,
|
||||||
"connector": CONNECTOR_ID,
|
"hub_id": hub["id"],
|
||||||
"hub_id": hub["id"],
|
"project_id": project["id"],
|
||||||
"project_id": project["id"],
|
"item_id": entry["id"],
|
||||||
"item_id": entry["id"],
|
},
|
||||||
},
|
"path": str(model_path),
|
||||||
"path": str(model_path),
|
"metadata": _build_metadata(item),
|
||||||
"metadata": _build_metadata(item),
|
}
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
# ---- push_ifcfed_interactive --------------------------------------------
|
# ---- push_ifcfed_interactive --------------------------------------------
|
||||||
|
|
||||||
@@ -278,7 +326,7 @@ class AutodeskConnector:
|
|||||||
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
|
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
|
||||||
hub = chosen["hub"]
|
hub = chosen["hub"]
|
||||||
project = chosen["project"]
|
project = chosen["project"]
|
||||||
folder = chosen["entry"]
|
folder = chosen["entries"][0]
|
||||||
|
|
||||||
file_name = prompt_for_filename(
|
file_name = prompt_for_filename(
|
||||||
title="Save Project",
|
title="Save Project",
|
||||||
@@ -378,7 +426,7 @@ class AutodeskConnector:
|
|||||||
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
|
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
|
||||||
hub = chosen["hub"]
|
hub = chosen["hub"]
|
||||||
project = chosen["project"]
|
project = chosen["project"]
|
||||||
folder = chosen["entry"]
|
folder = chosen["entries"][0]
|
||||||
|
|
||||||
file_name = prompt_for_filename(
|
file_name = prompt_for_filename(
|
||||||
title="Save Model",
|
title="Save Model",
|
||||||
|
|||||||
@@ -110,28 +110,57 @@ class _BaseDialog(ctk.CTkToplevel):
|
|||||||
|
|
||||||
|
|
||||||
class ProgressDialog(_BaseDialog):
|
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:
|
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 = ctk.CTkFrame(self)
|
||||||
body.pack(fill="both", expand=True, padx=20, pady=20)
|
body.pack(fill="both", expand=True, padx=20, pady=20)
|
||||||
|
|
||||||
self.message = ctk.CTkLabel(body, text="Working…", anchor="w")
|
self.title_label = ctk.CTkLabel(body, text="Working…", anchor="w", font=self._text_font)
|
||||||
self.message.pack(fill="x", anchor="w")
|
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 = 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.bar.start()
|
||||||
self._determinate = False
|
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._center_on_screen()
|
||||||
self.deiconify()
|
self.deiconify()
|
||||||
self.lift()
|
self.lift()
|
||||||
self.update()
|
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:
|
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 percent is None:
|
||||||
if self._determinate:
|
if self._determinate:
|
||||||
self.bar.configure(mode="indeterminate")
|
self.bar.configure(mode="indeterminate")
|
||||||
@@ -147,6 +176,29 @@ class ProgressDialog(_BaseDialog):
|
|||||||
except tk.TclError:
|
except tk.TclError:
|
||||||
pass
|
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:
|
class _ProgressContext:
|
||||||
def __init__(self, parent: tk.Misc | None, message: str) -> None:
|
def __init__(self, parent: tk.Misc | None, message: str) -> None:
|
||||||
@@ -154,7 +206,7 @@ class _ProgressContext:
|
|||||||
self.message = message
|
self.message = message
|
||||||
self.dialog: ProgressDialog | None = None
|
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)
|
self.dialog = ProgressDialog(self.message, self.parent)
|
||||||
return self.dialog.report
|
return self.dialog.report
|
||||||
|
|
||||||
@@ -197,9 +249,10 @@ class BrowseDialog(_BaseDialog):
|
|||||||
self.auth = auth
|
self.auth = auth
|
||||||
self.aps = aps
|
self.aps = aps
|
||||||
self.mode: Mode = mode
|
self.mode: Mode = mode
|
||||||
|
self.multi_select = mode == "model"
|
||||||
self.selected_hub: dict[str, Any] | None = None
|
self.selected_hub: dict[str, Any] | None = None
|
||||||
self.selected_project: 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._tree_entries: dict[str, dict[str, Any]] = {}
|
||||||
self._project_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 = self._make_treeview(self.projects_frame, "PROJECTS")
|
||||||
self.projects.bind("<<TreeviewSelect>>", lambda _e: self._project_changed())
|
self.projects.bind("<<TreeviewSelect>>", 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("<<TreeviewSelect>>", lambda _e: self._tree_selection_changed())
|
self.tree.bind("<<TreeviewSelect>>", lambda _e: self._tree_selection_changed())
|
||||||
self.tree.bind("<<TreeviewOpen>>", self._on_tree_open)
|
self.tree.bind("<<TreeviewOpen>>", self._on_tree_open)
|
||||||
|
|
||||||
@@ -257,14 +311,14 @@ class BrowseDialog(_BaseDialog):
|
|||||||
self.action_button.grid(row=0, column=2)
|
self.action_button.grid(row=0, column=2)
|
||||||
self.action_button.configure(state="disabled")
|
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))
|
ctk.CTkLabel(parent, text=header, anchor="w").pack(fill="x", padx=12, pady=(8, 0))
|
||||||
body = ctk.CTkFrame(parent, fg_color="transparent")
|
body = ctk.CTkFrame(parent, fg_color="transparent")
|
||||||
body.pack(fill="both", expand=True, padx=8, pady=8)
|
body.pack(fill="both", expand=True, padx=8, pady=8)
|
||||||
body.grid_rowconfigure(0, weight=1)
|
body.grid_rowconfigure(0, weight=1)
|
||||||
body.grid_columnconfigure(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")
|
tree.grid(row=0, column=0, sticky="nsew")
|
||||||
scrollbar = ctk.CTkScrollbar(body, orientation="vertical", command=tree.yview)
|
scrollbar = ctk.CTkScrollbar(body, orientation="vertical", command=tree.yview)
|
||||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||||
@@ -336,7 +390,7 @@ class BrowseDialog(_BaseDialog):
|
|||||||
if not isinstance(project, dict):
|
if not isinstance(project, dict):
|
||||||
return
|
return
|
||||||
self.selected_project = project
|
self.selected_project = project
|
||||||
self.selected_entry = None
|
self.selected_entries = []
|
||||||
self._refresh_action_button()
|
self._refresh_action_button()
|
||||||
self._clear_tree()
|
self._clear_tree()
|
||||||
with self._with_progress("Loading top folders"):
|
with self._with_progress("Loading top folders"):
|
||||||
@@ -400,41 +454,53 @@ class BrowseDialog(_BaseDialog):
|
|||||||
|
|
||||||
def _tree_selection_changed(self) -> None:
|
def _tree_selection_changed(self) -> None:
|
||||||
selection = self.tree.selection()
|
selection = self.tree.selection()
|
||||||
entry = self._tree_entries.get(selection[0]) if selection else None
|
entries: list[dict[str, Any]] = []
|
||||||
if isinstance(entry, dict) and not entry.get("__placeholder__"):
|
for iid in selection:
|
||||||
self.selected_entry = entry
|
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", "?")
|
name = entry.get("display_name") or entry.get("name") or entry.get("id", "?")
|
||||||
kind = entry.get("type", "entry")
|
kind = entry.get("type", "entry")
|
||||||
self.status.configure(text=f"Selected {kind}: {name}")
|
self.status.configure(text=f"Selected {kind}: {name}")
|
||||||
else:
|
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()
|
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:
|
def _refresh_action_button(self) -> None:
|
||||||
enabled = self.selected_project is not None and self.selected_entry is not None
|
enabled = self.selected_project is not None and bool(self._valid_entries())
|
||||||
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)
|
|
||||||
)
|
|
||||||
self.action_button.configure(state="normal" if enabled else "disabled")
|
self.action_button.configure(state="normal" if enabled else "disabled")
|
||||||
|
|
||||||
def _confirm(self) -> None:
|
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
|
return
|
||||||
self.result = {
|
self.result = {
|
||||||
"hub": self.selected_hub,
|
"hub": self.selected_hub,
|
||||||
"project": self.selected_project,
|
"project": self.selected_project,
|
||||||
"entry": self.selected_entry,
|
"entries": valid,
|
||||||
}
|
}
|
||||||
self._on_close()
|
self._on_close()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user