Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
34 changes: 33 additions & 1 deletion packages/core/src/lib/storedObjectOrigins.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
Expand Down Expand Up @@ -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();
});
});
24 changes: 22 additions & 2 deletions packages/core/src/lib/storedObjectOrigins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
5 changes: 5 additions & 0 deletions src/components/PrinterSettings/PrinterSettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -247,6 +249,7 @@ export function PrinterSettingsModal() {
</div>

<IllustrationFocusProvider>
<StoredObjectHoverProvider>
<div className="flex-1 flex min-h-0">
<div className="w-44 shrink-0 border-r border-border bg-surface-2/40 flex flex-col">
{activeTopTab === 'perLabel' && <PrinterIllustration />}
Expand All @@ -262,6 +265,7 @@ export function PrinterSettingsModal() {
/>
))}
</nav>
{activeTopTab === 'objects' && isDesktopShell && <PrinterStorageUsage />}
{activeTopTab === 'perLabel' && hasPerLabelOverrides && (
<div className="px-3 pb-3">
<ResetPerLabelButton
Expand All @@ -277,6 +281,7 @@ export function PrinterSettingsModal() {
{ActiveTab && <ActiveTab />}
</section>
</div>
</StoredObjectHoverProvider>
</IllustrationFocusProvider>

{SCRIPT_TOP_TABS.has(activeTopTab) && (
Expand Down
5 changes: 3 additions & 2 deletions src/components/PrinterSettings/PrinterStorage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<PrinterStorageBar />);
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 () => {
Expand Down
46 changes: 40 additions & 6 deletions src/components/PrinterSettings/PrinterStorage.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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)));

Expand Down Expand Up @@ -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)
Expand All @@ -64,9 +65,42 @@ export function PrinterStorageBar() {
{line}
</span>
</div>
{listing && listing.free.length > 0 && (
<p className="text-[10px] text-muted">{listing.free.map((f) => formatTemplate(loc.printerFreeFmt, { device: f.device, kb: kb(f.bytesFree) })).join(", ")}</p>
)}
</div>
);
}

/** 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 (
<div className="px-3 pb-3 pt-2 border-t border-border flex flex-col gap-2">
{drives.map((usage) => {
const share = hovered?.device === usage.device ? hovered : undefined;
return (
<div key={usage.device} className="flex flex-col gap-0.5">
<Tooltip content={loc.usageHint}>
<span className="text-[10px] text-muted cursor-help">
{formatTemplate(loc.usageFmt, { device: usage.device, used: kb(usage.used), free: kb(usage.free) })}
</span>
</Tooltip>
<MemoryBar used={usage.used} capacity={usage.capacity} highlight={share?.size ?? 0} />
{share && (
<span className="text-[10px] text-accent truncate" title={hostObjectPath(share)}>
{formatTemplate(loc.usageObjectFmt, {
path: hostObjectPath(share),
kb: kb(share.size),
percent: ((share.size / usage.capacity) * 100).toFixed(1),
})}
</span>
)}
</div>
);
})}
</div>
);
}
Expand Down
44 changes: 42 additions & 2 deletions src/components/PrinterSettings/StoredFontsTab.printer.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 } }));

Expand All @@ -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(
<StoredObjectHoverProvider>
<StoredFontsTab />
<PrinterStorageUsage />
</StoredObjectHoverProvider>,
);

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([
Expand Down
4 changes: 4 additions & 0 deletions src/components/PrinterSettings/StoredFontsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>): "unshippable" | "missingBytes" | undefined {
Expand All @@ -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.
Expand Down Expand Up @@ -112,6 +114,8 @@ export function StoredFontsTab() {
return (
<li
key={key}
onMouseEnter={() => 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"}`}
>
<span className="flex flex-col gap-0.5 min-w-0">
Expand Down
6 changes: 6 additions & 0 deletions src/components/PrinterSettings/StoredGraphicsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -128,6 +130,8 @@ export function StoredGraphicsTab() {
return (
<li
key={key}
onMouseEnter={() => 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"}`}
>
<span className="flex flex-col gap-0.5 min-w-0">
Expand Down Expand Up @@ -165,6 +169,8 @@ export function StoredGraphicsTab() {
return (
<li
key={key}
onMouseEnter={() => 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"
>
<span className="flex flex-col gap-0.5 min-w-0">
Expand Down
7 changes: 7 additions & 0 deletions src/components/PrinterSettings/storedObjectHover.tsx
Original file line number Diff line number Diff line change
@@ -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<string>();
return <StoredObjectHoverContext.Provider value={{ hoveredKey, setHoveredKey }}>{children}</StoredObjectHoverContext.Provider>;
}
18 changes: 18 additions & 0 deletions src/components/ui/MemoryBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="h-1.5 flex rounded overflow-hidden bg-surface-2" aria-hidden="true">
<div data-part="used" className="bg-muted/50" style={{ width: share(used - highlight) }} />
<div data-part="highlight" className="bg-accent" style={{ width: share(highlight) }} />
</div>
);
}
15 changes: 15 additions & 0 deletions src/hooks/useStoredObjectHover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createContext, useContext } from "react";

export interface StoredObjectHover {
/** A storage key, resolved against the current listing, so a row that is gone marks nothing. */
hoveredKey: string | undefined;
setHoveredKey: (key?: string) => void;
}

/** The lists and the drive bars sit in different subtrees, so one hover needs a shared slot. */
export const StoredObjectHoverContext = createContext<StoredObjectHover | null>(null);

/** Outside the provider a list still renders, it just reports its hover to nobody. */
export function useStoredObjectHover(): StoredObjectHover {
return useContext(StoredObjectHoverContext) ?? { hoveredKey: undefined, setHoveredKey: () => undefined };
}
Loading
Loading