diff --git a/README.md b/README.md index 7cc29b9f..07dafbe4 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ On desktop, **Connect data** also reads an Excel worksheet. **File → Settings - The **Objects** group selects the fonts and graphics the **Setup Script** uploads. - It lists cached images and deletes the unused ones. -- On desktop it reads what the printer holds, so every row says where its file stands. Show a stored graphic or let the printer draw a font sample. Delete objects on the printer. +- On desktop it reads what the printer holds, so every row says where its file stands and a bar shows how full each drive is. Show a stored graphic or let the printer draw a font sample. Delete objects on the printer. - Saved designs and label exports exclude **Setup Script** values such as printer name and locale. - **Clear** resets the **Setup Script** values and keeps the uploads. diff --git a/packages/core/src/lib/storedObjectOrigins.test.ts b/packages/core/src/lib/storedObjectOrigins.test.ts index eb9f327b..338e0655 100644 --- a/packages/core/src/lib/storedObjectOrigins.test.ts +++ b/packages/core/src/lib/storedObjectOrigins.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { deleteKnowledge, hostListing, listingHolds, originState, storedObjectOrigins } from "./storedObjectOrigins"; +import { deleteKnowledge, driveUsage, hostListing, listedObject, listingHolds, originState, storedObjectOrigins } from "./storedObjectOrigins"; import type { HostDirectory } from "./hostDirectory"; const object = (device: string, name: string, ext: string, size = 100) => ({ device, name, ext, size }); @@ -111,3 +111,35 @@ describe("deleteKnowledge", () => { expect(deleteKnowledge(row(false, false))).toBe("noCopy"); }); }); + +describe("driveUsage", () => { + it("counts every kind on the drive, not just one tab's", () => { + const listing = hostListing([ + dir("R", [object("R", "LOGO", "GRF", 100), object("R", "ARIAL", "TTF", 300), object("R", "LBL", "ZPL", 50)], 550), + ]); + expect(driveUsage(listing)).toEqual([{ device: "R", used: 450, free: 550, capacity: 1000 }]); + }); + + it("leaves out a drive that never said how much room is left", () => { + const listing = hostListing([dir("R", [object("R", "LOGO", "GRF", 100)], 900), dir("E", [object("E", "ARIAL", "TTF", 100)])]); + expect(driveUsage(listing).map((u) => u.device)).toEqual(["R"]); + }); + + it("leaves out the firmware drive, which no upload fills", () => { + const listing = hostListing([dir("Z", [object("Z", "TT0003M_", "FNT", 100)], 0)]); + expect(driveUsage(listing)).toEqual([]); + }); + + it("leaves out a drive with neither room nor content, which has nothing to show", () => { + expect(driveUsage(hostListing([dir("R", [], 0)]))).toEqual([]); + expect(driveUsage(hostListing([dir("R", [object("R", "LOGO", "GRF", 100)], 0)]))).toEqual([{ device: "R", used: 100, free: 0, capacity: 100 }]); + }); +}); + +describe("listedObject", () => { + it("hands back the object behind a key, whatever spelling the key came in", () => { + const listing = hostListing([dir("E", [object("E", "ARIAL", "TTF", 4096)])]); + expect(listedObject(listing, "e:arial.ttf")?.size).toBe(4096); + expect(listedObject(listing, "R:ARIAL.TTF")).toBeUndefined(); + }); +}); diff --git a/packages/core/src/lib/storedObjectOrigins.ts b/packages/core/src/lib/storedObjectOrigins.ts index 01c526db..9ba26131 100644 --- a/packages/core/src/lib/storedObjectOrigins.ts +++ b/packages/core/src/lib/storedObjectOrigins.ts @@ -32,8 +32,28 @@ function firmwareLast(a: HostObject, b: HostObject): number { return Number(!isWritableDevice(a.device)) - Number(!isWritableDevice(b.device)); } -export const listingHolds = (listing: HostListing, path: string): boolean => - listing.objects.some((o) => storageKey(hostObjectPath(o)) === storageKey(path)); +export const listedObject = (listing: HostListing, path: string): HostObject | undefined => + listing.objects.find((o) => storageKey(hostObjectPath(o)) === storageKey(path)); + +export const listingHolds = (listing: HostListing, path: string): boolean => listedObject(listing, path) !== undefined; + +export interface DriveUsage { + device: string; + /** Every object on the drive, whatever kind, so no tab's filter shrinks it. */ + used: number; + free: number; + /** An estimate: what the firmware itself occupies is in neither number. */ + capacity: number; +} + +/** A drive can only say how full it is once it reported its free space and has something to show. */ +export function driveUsage(listing: HostListing): DriveUsage[] { + return listing.free.flatMap(({ device, bytesFree }) => { + const used = listing.objects.reduce((sum, o) => (o.device === device ? sum + o.size : sum), 0); + const capacity = used + bytesFree; + return capacity > 0 ? [{ device, used, free: bytesFree, capacity }] : []; + }); +} export type HostPresence = "present" | "absent" | "unknown"; diff --git a/src/components/PrinterSettings/PrinterSettingsModal.tsx b/src/components/PrinterSettings/PrinterSettingsModal.tsx index f5017f89..7fcbd565 100644 --- a/src/components/PrinterSettings/PrinterSettingsModal.tsx +++ b/src/components/PrinterSettings/PrinterSettingsModal.tsx @@ -32,6 +32,8 @@ import { PreviewSettingsTab } from "./PreviewSettingsTab"; import { PrintTargetTab } from "./PrintTargetTab"; import { PrintQualityTab } from "./PrintQualityTab"; import { IllustrationFocusProvider, PrinterIllustration } from "./printerIllustration"; +import { PrinterStorageUsage } from "./PrinterStorage"; +import { StoredObjectHoverProvider } from "./storedObjectHover"; import { sectionHeadingCls } from "../ui/formStyles"; /** Sub-tab → top-tab. `satisfies` flags any PrinterSettingsTab union @@ -247,6 +249,7 @@ export function PrinterSettingsModal() { +
{activeTopTab === 'perLabel' && } @@ -262,6 +265,7 @@ export function PrinterSettingsModal() { /> ))} + {activeTopTab === 'objects' && isDesktopShell && } {activeTopTab === 'perLabel' && hasPerLabelOverrides && (
}
+ {SCRIPT_TOP_TABS.has(activeTopTab) && ( diff --git a/src/components/PrinterSettings/PrinterStorage.test.tsx b/src/components/PrinterSettings/PrinterStorage.test.tsx index 81a9cbb8..2959d9be 100644 --- a/src/components/PrinterSettings/PrinterStorage.test.tsx +++ b/src/components/PrinterSettings/PrinterStorage.test.tsx @@ -54,14 +54,15 @@ beforeEach(() => { }); describe("PrinterStorageBar", () => { - it("reads the printer on request and reports the free space of every drive that answered", async () => { + it("reads the printer on request and stops saying it never did", async () => { readPrinterObjects.mockResolvedValue({ kind: "ok", value: listing }); const r = render(); expect(r.getByText(loc.printerNotRead)).toBeTruthy(); await act(async () => { fireEvent.click(r.getByText(loc.readPrinter)); }); - expect(r.getByText("R: 7292 KB free, E: 46848 KB free")).toBeTruthy(); + expect(readPrinterObjects).toHaveBeenCalledOnce(); + expect(r.queryByText(loc.printerNotRead)).toBeNull(); }); it("names a failed read instead of an empty listing", async () => { diff --git a/src/components/PrinterSettings/PrinterStorage.tsx b/src/components/PrinterSettings/PrinterStorage.tsx index ee0b0ca1..710dac30 100644 --- a/src/components/PrinterSettings/PrinterStorage.tsx +++ b/src/components/PrinterSettings/PrinterStorage.tsx @@ -1,8 +1,8 @@ import { useState } from "react"; import { InformationCircleIcon } from "@heroicons/react/16/solid"; -import type { HostObject } from "@zplab/core/lib/hostDirectory"; +import { hostObjectPath, type HostObject } from "@zplab/core/lib/hostDirectory"; import { isWritableDevice } from "@zplab/core/lib/storagePath"; -import { deleteKnowledge, originState, type DeleteKnowledge, type OriginState, type StoredObjectKind, type StoredObjectOrigin } from "@zplab/core/lib/storedObjectOrigins"; +import { deleteKnowledge, driveUsage, listedObject, originState, type DeleteKnowledge, type OriginState, type StoredObjectKind, type StoredObjectOrigin } from "@zplab/core/lib/storedObjectOrigins"; import { isDesktopShell } from "../../lib/platform"; import { usePrinterListing } from "../../hooks/usePrinterListing"; import { useT } from "../../hooks/useT"; @@ -13,9 +13,11 @@ import type { PrinterObjectsState } from "../../store/slices/printerObjectsSlice import { failureText, readingText } from "../../lib/printerStatusText"; import { selectPrinterObjects, useLabelStore } from "../../store/labelStore"; import { ConfirmDialog } from "../ui/ConfirmDialog"; +import { MemoryBar } from "../ui/MemoryBar"; import { Tooltip } from "../ui/Tooltip"; import { buttonCls, disabledCls, sectionHeadingCls, zplCommandTagCls } from "../ui/formStyles"; import { PrinterImageDialog } from "./PrinterImageDialog"; +import { useStoredObjectHover } from "../../hooks/useStoredObjectHover"; const kb = (bytes: number): string => String(bytes === 0 ? 0 : Math.max(1, Math.round(bytes / 1024))); @@ -44,7 +46,6 @@ export function PrinterStorageBar() { const state = useLabelStore(selectPrinterObjects); const step = useLabelStore((s) => s.printerReading); const read = useLabelStore((s) => s.readPrinterObjects); - const listing = usePrinterListing(); const line = step !== undefined ? readingText(status, step) @@ -64,9 +65,42 @@ export function PrinterStorageBar() { {line}
- {listing && listing.free.length > 0 && ( -

{listing.free.map((f) => formatTemplate(loc.printerFreeFmt, { device: f.device, kb: kb(f.bytesFree) })).join(", ")}

- )} +
+ ); +} + +/** Beside the lists rather than inside one, so it stands while the tabs change. */ +export function PrinterStorageUsage() { + const loc = useT().printerSettings.objects; + const listing = usePrinterListing(); + const { hoveredKey } = useStoredObjectHover(); + const drives = listing ? driveUsage(listing) : []; + const hovered = listing && hoveredKey ? listedObject(listing, hoveredKey) : undefined; + if (drives.length === 0) return null; + return ( +
+ {drives.map((usage) => { + const share = hovered?.device === usage.device ? hovered : undefined; + return ( +
+ + + {formatTemplate(loc.usageFmt, { device: usage.device, used: kb(usage.used), free: kb(usage.free) })} + + + + {share && ( + + {formatTemplate(loc.usageObjectFmt, { + path: hostObjectPath(share), + kb: kb(share.size), + percent: ((share.size / usage.capacity) * 100).toFixed(1), + })} + + )} +
+ ); + })}
); } diff --git a/src/components/PrinterSettings/StoredFontsTab.printer.test.tsx b/src/components/PrinterSettings/StoredFontsTab.printer.test.tsx index c99e7934..3d736ebf 100644 --- a/src/components/PrinterSettings/StoredFontsTab.printer.test.tsx +++ b/src/components/PrinterSettings/StoredFontsTab.printer.test.tsx @@ -1,8 +1,10 @@ // @vitest-environment jsdom import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; -import { render, cleanup, act } from "@testing-library/react"; +import { render, cleanup, fireEvent, act } from "@testing-library/react"; import { loadFontBytes, removeFont } from "@zplab/core/lib/fontCache"; import { StoredFontsTab } from "./StoredFontsTab"; +import { PrinterStorageUsage } from "./PrinterStorage"; +import { StoredObjectHoverProvider } from "./storedObjectHover"; import { useLabelStore } from "../../store/labelStore"; import { DEFAULT_PRINT_TARGET } from "../../lib/printTarget"; import { fallbackTranslations as en } from "../../locales"; @@ -13,7 +15,7 @@ vi.mock("../../lib/platform", async (importOriginal) => ({ ...(await importOrigi const loc = en.printerSettings.objects; const fontsLoc = en.printerSettings.fonts; const object = (device: string, name: string, ext: string, size = 1000) => ({ device, name, ext, size }); -const dir = (device: string, objects: HostDirectory["objects"]): HostDirectory => ({ device, objects, bytesFree: undefined }); +const dir = (device: string, objects: HostDirectory["objects"], bytesFree?: number): HostDirectory => ({ device, objects, bytesFree }); const listed = (directories: HostDirectory[]) => act(() => useLabelStore.setState({ printerObjects: { phase: "done", key: "net:172.17.17.175:9100", at: 0, directories } })); @@ -34,7 +36,45 @@ afterEach(() => { removeFont("E:ARIAL.TTF"); }); +/** The modal puts the lists and the drive bars under one provider, so a hover test needs both. */ +const withUsage = () => + render( + + + + , + ); + describe("StoredFontsTab against the printer", () => { + it("shows how full a drive is, counting the graphics the tab does not list", () => { + listed([dir("E", [object("E", "ARIAL", "TTF", 1024), object("E", "LOGO", "GRF", 1024)], 2048), dir("R", [])]); + const r = withUsage(); + expect(r.getByText("E: 2 KB used, 2 KB free")).toBeTruthy(); + // R: never said how much room is left, so it gets no bar. + expect(r.queryByText(/^R:/)).toBeNull(); + }); + + it("names the hovered row's share and lifts it out of the used block", () => { + listed([dir("E", [object("E", "ARIAL", "TTF", 1024), object("E", "LOGO", "GRF", 1024)], 2048)]); + const r = withUsage(); + const width = (part: string) => (r.container.querySelector(`[data-part="${part}"]`) as HTMLElement).style.width; + expect(width("highlight")).toBe("0%"); + fireEvent.mouseEnter(r.getByTitle("E:ARIAL.TTF").closest("li") as HTMLElement); + expect(r.getByText("E:ARIAL.TTF: 1 KB, 25.0%")).toBeTruthy(); + expect(width("highlight")).toBe("25%"); + expect(width("used")).toBe("25%"); + }); + + it("drops the mark when the next listing no longer holds the hovered file", () => { + listed([dir("E", [object("E", "ARIAL", "TTF", 1024), object("E", "LOGO", "GRF", 1024)], 2048)]); + const r = withUsage(); + fireEvent.mouseEnter(r.getByTitle("E:ARIAL.TTF").closest("li") as HTMLElement); + expect(r.getByText("E:ARIAL.TTF: 1 KB, 25.0%")).toBeTruthy(); + // The row can vanish under a pointer that never left it, so a fresh listing has the last word. + listed([dir("E", [object("E", "LOGO", "GRF", 1024)], 3072)]); + expect(r.queryByText(/E:ARIAL\.TTF: /)).toBeNull(); + }); + it("marks each row with where it stands and keeps one row per file", () => { act(() => useLabelStore.setState({ printerProfile: { setupFonts: [{ path: "E:ARIAL.TTF" }, { path: "R:GONE.TTF" }] } })); listed([ diff --git a/src/components/PrinterSettings/StoredFontsTab.tsx b/src/components/PrinterSettings/StoredFontsTab.tsx index 5a4d745f..1bbb07b4 100644 --- a/src/components/PrinterSettings/StoredFontsTab.tsx +++ b/src/components/PrinterSettings/StoredFontsTab.tsx @@ -16,6 +16,7 @@ import { isDesktopShell } from "../../lib/platform"; import { PrinterObjectActions, PrinterOriginMark, PrinterStorageBar, StoredObjectsHeading } from "./PrinterStorage"; import { buttonCls, disabledCls } from "../ui/formStyles"; import { ConfirmDialog } from "../ui/ConfirmDialog"; +import { useStoredObjectHover } from "../../hooks/useStoredObjectHover"; /** Why a profile row cannot be provisioned. */ function rowIssue(key: string, cachedKeys: ReadonlySet): "unshippable" | "missingBytes" | undefined { @@ -38,6 +39,7 @@ export function StoredFontsTab() { const shipsToo = (path: string) => customFonts?.some((m) => m.embedInZpl && m.path !== undefined && storageRefMatchesPath(m.path, path)) ?? false; const patchPrinterProfileWith = useLabelStore((s) => s.patchPrinterProfileWith); const loc = t.printerSettings.fonts; + const { setHoveredKey } = useStoredObjectHover(); const frozen = useLabelStore(selectEditorFrozen); // A cache row is named by its printer path, so profile and cache rows share one identity rule. @@ -112,6 +114,8 @@ export function StoredFontsTab() { return (
  • setHoveredKey(key)} + onMouseLeave={() => setHoveredKey(undefined)} className={`flex items-center justify-between gap-3 px-2 py-1.5 rounded border ${issue ? "border-warning/30 bg-warning/5" : "border-transparent hover:border-border-2 hover:bg-surface-2/40 transition-colors"}`} > diff --git a/src/components/PrinterSettings/StoredGraphicsTab.tsx b/src/components/PrinterSettings/StoredGraphicsTab.tsx index 04e5d4be..1d9d09a7 100644 --- a/src/components/PrinterSettings/StoredGraphicsTab.tsx +++ b/src/components/PrinterSettings/StoredGraphicsTab.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from "react"; import { InformationCircleIcon } from "@heroicons/react/16/solid"; import { Tooltip } from "../ui/Tooltip"; import { ConfirmDialog } from "../ui/ConfirmDialog"; +import { useStoredObjectHover } from "../../hooks/useStoredObjectHover"; import { usePrinterListing } from "../../hooks/usePrinterListing"; import { useT } from "../../hooks/useT"; import { useUpload } from "../../hooks/useUpload"; @@ -27,6 +28,7 @@ export function StoredGraphicsTab() { const patchPrinterProfileWith = useLabelStore((s) => s.patchPrinterProfileWith); const sendSetupGraphic = useLabelStore((s) => s.sendSetupGraphic); const loc = t.printerSettings.objects; + const { setHoveredKey } = useStoredObjectHover(); const rows = storedGraphicRows(pages); const setupPaths = (setupGraphics ?? []).map((g) => g.path); @@ -128,6 +130,8 @@ export function StoredGraphicsTab() { return (
  • setHoveredKey(key)} + onMouseLeave={() => setHoveredKey(undefined)} className={`flex items-center justify-between gap-3 px-2 py-1.5 rounded border ${origin.inSetup ? "border-border-2/60 bg-surface-2/20" : "border-transparent hover:border-border-2 hover:bg-surface-2/40 transition-colors"}`} > @@ -165,6 +169,8 @@ export function StoredGraphicsTab() { return (
  • setHoveredKey(key)} + onMouseLeave={() => setHoveredKey(undefined)} className="flex items-center justify-between gap-3 px-2 py-1.5 rounded border border-transparent hover:border-border-2 hover:bg-surface-2/40 transition-colors" > diff --git a/src/components/PrinterSettings/storedObjectHover.tsx b/src/components/PrinterSettings/storedObjectHover.tsx new file mode 100644 index 00000000..1c940860 --- /dev/null +++ b/src/components/PrinterSettings/storedObjectHover.tsx @@ -0,0 +1,7 @@ +import { useState, type ReactNode } from "react"; +import { StoredObjectHoverContext } from "../../hooks/useStoredObjectHover"; + +export function StoredObjectHoverProvider({ children }: { children: ReactNode }) { + const [hoveredKey, setHoveredKey] = useState(); + return {children}; +} diff --git a/src/components/ui/MemoryBar.tsx b/src/components/ui/MemoryBar.tsx new file mode 100644 index 00000000..70da1881 --- /dev/null +++ b/src/components/ui/MemoryBar.tsx @@ -0,0 +1,18 @@ +interface Props { + used: number; + capacity: number; + /** A slice of `used`, not an addition to it. */ + highlight?: number; +} + +/** Proportions only. The caption beside it carries the numbers, so this stays out of the a11y tree. */ +export function MemoryBar({ used, capacity, highlight = 0 }: Props) { + if (capacity <= 0) return null; + const share = (bytes: number) => `${Math.max(0, Math.min(100, (bytes / capacity) * 100))}%`; + return ( +