From c71cac7dc7d79dbd40520c0689571393edc112c9 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 14 Aug 2026 23:33:19 +0200 Subject: [PATCH] fix(downloads): animate package contents as one panel --- .../downloads/VirtualizedDownloadsBody.tsx | 16 ++- .../download-disclosure-transition.ts | 135 ++++++++++++------ src/renderer/views/downloads/downloads.css | 4 + tests/downloads-view.test.tsx | 75 ++++++++-- 4 files changed, 169 insertions(+), 61 deletions(-) diff --git a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx index 6e1ea7a..47ae459 100644 --- a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx +++ b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx @@ -7,6 +7,7 @@ import { mergeDownloadDisclosureRows, prepareDownloadDisclosureTransition, scheduleDownloadDisclosureActivation, + stableDownloadDisclosureRows, type DownloadDisclosureRow } from "./download-disclosure-transition"; import type { DownloadsViewActions, DownloadsViewModel } from "./DownloadsView"; @@ -69,7 +70,16 @@ function disclosureOpacity(row: DownloadLogicalRow | DownloadDisclosureRow): num return "disclosureOpacity" in row && typeof row.disclosureOpacity === "number" ? row.disclosureOpacity : 1; } -function renderVirtualRow(row: DownloadLogicalRow, model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement { +function renderVirtualRow(row: DownloadLogicalRow | DownloadDisclosureRow, model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement { + if (row.type === "item-group") { + return ( +
+ {row.items.map((entry) => ( + + ))} +
+ ); + } if (row.type === "item") { return ( @@ -140,7 +150,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D }; }, [model.disclosureRevision, model.displayMode]); - const renderedRows = useMemo(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : desiredRows, [desiredRows, transitionRows]); + const renderedRows = useMemo(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : stableDownloadDisclosureRows(desiredRows), [desiredRows, transitionRows]); const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(renderedRows, { scrollTop: viewport.scrollTop, viewportHeight: viewport.viewportHeight, @@ -155,7 +165,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D {!state ? (
{virtualWindow.rows.map((entry) => ( -
+
{renderVirtualRow(entry.source, model, actions)}
))} diff --git a/src/renderer/views/downloads/download-disclosure-transition.ts b/src/renderer/views/downloads/download-disclosure-transition.ts index e4210e0..ab5b27f 100644 --- a/src/renderer/views/downloads/download-disclosure-transition.ts +++ b/src/renderer/views/downloads/download-disclosure-transition.ts @@ -2,6 +2,7 @@ import type { DownloadLogicalRow } from "./downloads-model"; import { DOWNLOAD_FILE_ROW_HEIGHT, calculateDownloadVirtualWindow, + type DownloadVirtualRowInput, type DownloadVirtualWindowOptions } from "./download-virtualizer"; @@ -26,28 +27,57 @@ export function scheduleDownloadDisclosureActivation( export type DownloadDisclosurePhase = "stable" | "entering" | "leaving"; -export type DownloadDisclosureRow = DownloadLogicalRow & { +type DownloadItemLogicalRow = Extract; + +type DownloadDisclosureMeta = { disclosureOpacity: number; disclosurePhase: DownloadDisclosurePhase; }; -function stableRow(row: DownloadLogicalRow): DownloadDisclosureRow { - return { ...row, disclosureOpacity: 1, disclosurePhase: "stable" }; -} +export type DownloadDisclosureLogicalRow = DownloadLogicalRow & DownloadDisclosureMeta; + +export type DownloadDisclosureGroupRow = DownloadVirtualRowInput & DownloadDisclosureMeta & { + type: "item-group"; + packageId: string; + items: DownloadItemLogicalRow[]; +}; + +export type DownloadDisclosureRow = DownloadDisclosureLogicalRow | DownloadDisclosureGroupRow; type DownloadDisclosureSourceRow = DownloadLogicalRow | DownloadDisclosureRow; -function disclosureOpacity(row: DownloadDisclosureSourceRow): number { - return "disclosureOpacity" in row ? row.disclosureOpacity : 1; +function stableRow(row: DownloadLogicalRow): DownloadDisclosureLogicalRow { + return { ...row, disclosureOpacity: 1, disclosurePhase: "stable" }; +} + +function itemGroup( + packageId: string, + items: readonly DownloadItemLogicalRow[], + height: number, + phase: Exclude +): DownloadDisclosureGroupRow { + return { + type: "item-group", + id: `${packageId}:items`, + packageId, + items: items.map((row) => ({ ...row, height: DOWNLOAD_FILE_ROW_HEIGHT })), + height, + disclosureOpacity: 1, + disclosurePhase: phase + }; } export function stableDownloadDisclosureRows(rows: readonly DownloadLogicalRow[]): DownloadDisclosureRow[] { return rows.map(stableRow); } -function itemRowsByPackage(rows: readonly DownloadDisclosureSourceRow[]): Map { - const grouped = new Map(); +function itemRowsByPackage(rows: readonly DownloadDisclosureSourceRow[]): Map { + const grouped = new Map(); for (const row of rows) { + if (row.type === "item-group") { + grouped.set(row.packageId, row.items); + continue; + } if (row.type !== "item") continue; const entries = grouped.get(row.packageId) ?? []; entries.push(row); @@ -56,6 +86,10 @@ function itemRowsByPackage(rows: readonly DownloadDisclosureSourceRow[]): Map { + return new Map(rows.filter((row): row is DownloadDisclosureGroupRow => row.type === "item-group").map((row) => [row.packageId, row])); +} + export function prepareDownloadDisclosureTransition( current: readonly DownloadDisclosureSourceRow[], desired: readonly DownloadLogicalRow[] @@ -65,14 +99,9 @@ export function prepareDownloadDisclosureTransition( } const currentItems = itemRowsByPackage(current); + const currentGroups = groupRowsByPackage(current); const currentPackages = new Map(current.filter((row) => row.type === "package").map((row) => [row.packageId, row])); - const desiredItems = new Map(); - for (const row of desired) { - if (row.type !== "item") continue; - const entries = desiredItems.get(row.packageId) ?? []; - entries.push(row); - desiredItems.set(row.packageId, entries); - } + const desiredItems = itemRowsByPackage(desired); let animatedItemCount = 0; for (const row of desired) { @@ -93,6 +122,7 @@ export function prepareDownloadDisclosureTransition( const before = currentItems.get(row.packageId) ?? []; const after = desiredItems.get(row.packageId) ?? []; const previousPackage = currentPackages.get(row.packageId); + const previousGroup = currentGroups.get(row.packageId); const disclosureChanged = previousPackage?.type === "package" && previousPackage.packageRow.collapsed !== row.packageRow.collapsed; if (!disclosureChanged) { rows.push(...after.map(stableRow)); @@ -100,21 +130,12 @@ export function prepareDownloadDisclosureTransition( } if (row.packageRow.collapsed && before.length > 0) { animated = true; - rows.push(...before.map((entry) => ({ ...entry, disclosureOpacity: disclosureOpacity(entry), disclosurePhase: "leaving" as const }))); + rows.push(itemGroup(row.packageId, before, previousGroup?.height ?? before.length * DOWNLOAD_FILE_ROW_HEIGHT, "leaving")); continue; } - const beforeById = new Map(before.map((entry) => [entry.id, entry])); - for (const entry of after) { - const previous = beforeById.get(entry.id); - if (!previous) { - animated = true; - rows.push({ ...entry, height: 0, disclosureOpacity: 0, disclosurePhase: "entering" }); - } else if (("disclosurePhase" in previous && previous.disclosurePhase === "leaving") || previous.height === 0) { - animated = true; - rows.push({ ...entry, height: previous.height, disclosureOpacity: disclosureOpacity(previous), disclosurePhase: "entering" }); - } else { - rows.push(stableRow(entry)); - } + if (after.length > 0) { + animated = true; + rows.push(itemGroup(row.packageId, after, previousGroup?.height ?? 0, "entering")); } } return { animated, rows }; @@ -124,33 +145,59 @@ export function mergeDownloadDisclosureRows( transition: readonly DownloadDisclosureRow[], desired: readonly DownloadLogicalRow[] ): DownloadDisclosureRow[] { - const transitionById = new Map(transition.map((row) => [`${row.type}:${row.id}`, row])); - const desiredIds = new Set(desired.map((row) => `${row.type}:${row.id}`)); - const leavingByPackage = new Map(); - for (const row of transition) { - if (row.type !== "item" || row.disclosurePhase !== "leaving" || desiredIds.has(`item:${row.id}`)) continue; - const entries = leavingByPackage.get(row.packageId) ?? []; - entries.push(row); - leavingByPackage.set(row.packageId, entries); + const transitionPackages = new Map(transition.filter((row) => row.type === "package").map((row) => [row.packageId, row])); + const transitionGroups = groupRowsByPackage(transition); + const desiredItems = itemRowsByPackage(desired); + const desiredPackages = new Map(desired.filter((row) => row.type === "package").map((row) => [row.packageId, row])); + const freshGroupItems = new Map(); + let groupedItemCount = 0; + for (const group of transitionGroups.values()) { + const packageRow = desiredPackages.get(group.packageId); + if (!packageRow || packageRow.type !== "package") continue; + const items = desiredItems.get(group.packageId) ?? packageRow.packageRow.items.map((item): DownloadItemLogicalRow => ({ + type: "item", + id: item.id, + packageId: item.packageId, + item, + height: DOWNLOAD_FILE_ROW_HEIGHT + })); + groupedItemCount += items.length; + if (groupedItemCount > DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS) { + return stableDownloadDisclosureRows(desired); + } + freshGroupItems.set(group.packageId, items); } - const rows: DownloadDisclosureRow[] = []; + for (const row of desired) { - const animated = transitionById.get(`${row.type}:${row.id}`); - rows.push(animated - ? { ...row, height: animated.height, disclosureOpacity: animated.disclosureOpacity, disclosurePhase: animated.disclosurePhase } + if (row.type !== "package") continue; + const animatedPackage = transitionPackages.get(row.packageId); + rows.push(animatedPackage?.type === "package" + ? { ...row, height: animatedPackage.height, disclosureOpacity: animatedPackage.disclosureOpacity, disclosurePhase: animatedPackage.disclosurePhase } : stableRow(row)); - if (row.type === "package") rows.push(...(leavingByPackage.get(row.packageId) ?? [])); + + const group = transitionGroups.get(row.packageId); + if (!group) { + rows.push(...(desiredItems.get(row.packageId) ?? []).map(stableRow)); + continue; + } + + const freshItems = freshGroupItems.get(row.packageId) ?? []; + rows.push({ + ...group, + items: freshItems, + height: group.height > 0 ? freshItems.length * DOWNLOAD_FILE_ROW_HEIGHT : 0 + }); } return rows; } export function activateDownloadDisclosureTransition(rows: readonly DownloadDisclosureRow[]): DownloadDisclosureRow[] { return rows.map((row) => { - if (row.type !== "item" || row.disclosurePhase === "stable") return row; + if (row.type !== "item-group") return row; return row.disclosurePhase === "entering" - ? { ...row, height: DOWNLOAD_FILE_ROW_HEIGHT, disclosureOpacity: 1 } - : { ...row, height: 0, disclosureOpacity: 0 }; + ? { ...row, height: row.items.length * DOWNLOAD_FILE_ROW_HEIGHT } + : { ...row, height: 0 }; }); } diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css index 759563d..f4a2bba 100644 --- a/src/renderer/views/downloads/downloads.css +++ b/src/renderer/views/downloads/downloads.css @@ -306,6 +306,10 @@ height: var(--downloads-virtual-row-height); } +.downloads-disclosure-item-group { + width: 100%; +} + .downloads-package-card { box-sizing: border-box; width: 100%; diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 24887ae..b278b0a 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -253,7 +253,7 @@ describe("virtualisierte Paketanimation", () => { expect(cancelled).toContain(2); }); - it("fährt neue Unterzeilen von null auf ihre feste Höhe aus und verschiebt Folgepakete unter stabilen IDs", () => { + it("fährt einen gemeinsamen Paketbereich aus und lässt seine Unterzeilen auf fester Höhe", () => { const collapsed = logicalRows([packageA.id]); const expanded = logicalRows([]); const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(collapsed), expanded); @@ -261,15 +261,18 @@ describe("virtualisierte Paketanimation", () => { expect(prepared.animated).toBe(true); expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([ ["package-a", 40, "stable", 1], - ["a-1", 0, "entering", 0], - ["a-2", 0, "entering", 0], + ["package-a:items", 0, "entering", 1], ["package-b", 40, "stable", 1], ["b-1", 38, "stable", 1] ]); + const preparedGroup = prepared.rows.find((row) => row.type === "item-group"); + expect(preparedGroup?.type === "item-group" ? preparedGroup.items.map((row) => [row.id, row.height]) : []).toEqual([ + ["a-1", 38], + ["a-2", 38] + ]); expect(activateDownloadDisclosureTransition(prepared.rows).map((row) => [row.id, row.height, row.disclosureOpacity])).toEqual([ ["package-a", 40, 1], - ["a-1", 38, 1], - ["a-2", 38, 1], + ["package-a:items", 76, 1], ["package-b", 40, 1], ["b-1", 38, 1] ]); @@ -283,22 +286,22 @@ describe("virtualisierte Paketanimation", () => { expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.downloads-virtual-row\s*\{[^}]*transition-duration:\s*1500ms, 1500ms, 1500ms !important;/s); }); - it("behält ausfahrende Unterzeilen bis zum Animationsende und reduziert ihre Höhe auf null", () => { + it("schneidet beim Einfahren den gemeinsamen Paketbereich ab statt Unterzeilen zusammenzuziehen", () => { const expanded = logicalRows([]); const collapsed = logicalRows([packageA.id]); const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(expanded), collapsed); expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([ ["package-a", 40, "stable", 1], - ["a-1", 38, "leaving", 1], - ["a-2", 38, "leaving", 1], + ["package-a:items", 76, "leaving", 1], ["package-b", 40, "stable", 1], ["b-1", 38, "stable", 1] ]); + const preparedGroup = prepared.rows.find((row) => row.type === "item-group"); + expect(preparedGroup?.type === "item-group" ? preparedGroup.items.map((row) => row.height) : []).toEqual([38, 38]); expect(activateDownloadDisclosureTransition(prepared.rows).map((row) => [row.id, row.height, row.disclosureOpacity])).toEqual([ ["package-a", 40, 1], - ["a-1", 0, 0], - ["a-2", 0, 0], + ["package-a:items", 0, 1], ["package-b", 40, 1], ["b-1", 38, 1] ]); @@ -402,15 +405,59 @@ describe("virtualisierte Paketanimation", () => { const entering = activateDownloadDisclosureTransition(prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(logicalRows([packageA.id])), logicalRows([])).rows); const updated = logicalRows([]).map((row) => row.type === "item" && row.id === "a-1" ? { ...row, item: { ...row.item, downloadedBytes: 987_654_321 } } : row); const merged = mergeDownloadDisclosureRows(entering, updated); - const updatedItem = merged.find((row) => row.type === "item" && row.id === "a-1"); - expect(updatedItem?.type === "item" ? updatedItem.item.downloadedBytes : 0).toBe(987_654_321); - expect(updatedItem).toEqual(expect.objectContaining({ disclosurePhase: "entering", height: 38, disclosureOpacity: 1 })); + const enteringGroup = merged.find((row) => row.type === "item-group"); + const updatedItem = enteringGroup?.type === "item-group" ? enteringGroup.items.find((row) => row.id === "a-1") : null; + expect(updatedItem?.item.downloadedBytes).toBe(987_654_321); + expect(enteringGroup).toEqual(expect.objectContaining({ disclosurePhase: "entering", height: 76, disclosureOpacity: 1 })); const collapsed = logicalRows([packageA.id]); const leaving = activateDownloadDisclosureTransition(prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(logicalRows([])), collapsed).rows); const reversed = prepareDownloadDisclosureTransition(leaving, logicalRows([])); expect(reversed.animated).toBe(true); - expect(reversed.rows.filter((row) => row.packageId === packageA.id && row.type === "item").every((row) => row.disclosurePhase === "entering")).toBe(true); + expect(reversed.rows.find((row) => row.packageId === packageA.id && row.type === "item-group")).toEqual(expect.objectContaining({ disclosurePhase: "entering", height: 0 })); + }); + + it("gleicht die Unterzeilenmenge einer laufenden Paketbewegung sofort mit dem aktuellen Modell ab", () => { + const entering = activateDownloadDisclosureTransition(prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(logicalRows([packageA.id])), logicalRows([])).rows); + const current = logicalRows([]); + const replacement = item("a-3", packageA.id, "queued"); + const updated = current.flatMap((row) => { + if (row.type === "item" && row.id === "a-2") { + return [{ ...row, id: replacement.id, item: replacement }]; + } + return [row]; + }); + const merged = mergeDownloadDisclosureRows(entering, updated); + const group = merged.find((row) => row.type === "item-group"); + + expect(group?.type === "item-group" ? group.items.map((row) => row.id) : []).toEqual(["a-1", "a-3"]); + expect(group).toEqual(expect.objectContaining({ height: 76 })); + }); + + it("bricht eine laufende Gruppenbewegung ab wenn ein Filterwechsel die Virtualisierungsgrenze überschreitet", () => { + const entering = activateDownloadDisclosureTransition(prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(logicalRows([packageA.id])), logicalRows([])).rows); + const itemIds = Array.from({ length: DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS + 100 }, (_, index) => `expanded-${index}`); + const expandedPackage = pkg(packageA.id, packageA.name, itemIds); + const expandedItems = Object.fromEntries(itemIds.map((id) => [id, item(id, packageA.id, "queued")])); + const expandedRows = buildDownloadLogicalRows(buildDownloadsViewModel({ + packageOrder: [expandedPackage.id], + packages: { [expandedPackage.id]: expandedPackage }, + items: expandedItems, + displayMode: "packages", + filter: "all", + providerFilter: "all", + query: "", + collapsedPackageIds: [], + selectedIds: [], + hideExtractedItems: false, + showAllPackages: true, + renderLimit: itemIds.length + 1 + })); + const merged = mergeDownloadDisclosureRows(entering, expandedRows); + const window = calculateDownloadVirtualWindow(merged, { scrollTop: 0, viewportHeight: 720, overscan: 8 }); + + expect(merged.some((row) => row.type === "item-group")).toBe(false); + expect(window.rows.length).toBeLessThan(40); }); });