diff --git a/electron/main/services/cursorFiles.test.ts b/electron/main/services/cursorFiles.test.ts index 59e70266..5f784c65 100644 --- a/electron/main/services/cursorFiles.test.ts +++ b/electron/main/services/cursorFiles.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { groupCursorFiles, isCursorFileName, isUsableCursorFile } from './cursorFiles'; +import { bmpWithExplicitAlpha, groupCursorFiles, isCursorFileName, isUsableCursorFile } from './cursorFiles'; describe('isCursorFileName', () => { it('accepts the stock cursor set, size variants and cursor.res', () => { @@ -24,6 +24,48 @@ describe('isUsableCursorFile', () => { }); }); +function rgbBmp(pixels: number[]): Buffer { + const bmp = Buffer.alloc(54 + pixels.length); + bmp.write('BM', 0, 'latin1'); + bmp.writeUInt32LE(bmp.length, 2); + bmp.writeUInt32LE(54, 10); + bmp.writeUInt32LE(40, 14); + bmp.writeInt32LE(2, 18); + bmp.writeInt32LE(1, 22); + bmp.writeUInt16LE(1, 26); + bmp.writeUInt16LE(32, 28); + Buffer.from(pixels).copy(bmp, 54); + return bmp; +} + +describe('bmpWithExplicitAlpha', () => { + it('re-headers a 32-bit BI_RGB image with alpha as BITMAPV5 with an alpha mask', () => { + const pixels = [0xff, 0xff, 0xff, 0x00, 0x10, 0x20, 0x30, 0xff]; + const out = bmpWithExplicitAlpha(rgbBmp(pixels)); + expect(out.readUInt32LE(2)).toBe(out.length); + expect(out.readUInt32LE(10)).toBe(138); + expect(out.readUInt32LE(14)).toBe(124); + expect(out.readInt32LE(18)).toBe(2); + expect(out.readInt32LE(22)).toBe(1); + expect(out.readUInt32LE(30)).toBe(3); + expect(out.readUInt32LE(66)).toBe(0xff000000); + expect([...out.subarray(138)]).toEqual(pixels); + }); + + it('leaves an all-zero alpha channel alone, since SDL loads it opaque', () => { + const bmp = rgbBmp([1, 2, 3, 0, 4, 5, 6, 0]); + expect(bmpWithExplicitAlpha(bmp)).toBe(bmp); + }); + + it('passes through images that already carry masks, and junk', () => { + const v5 = rgbBmp([1, 2, 3, 0x80, 4, 5, 6, 0]); + v5.writeUInt32LE(3, 30); + expect(bmpWithExplicitAlpha(v5)).toBe(v5); + const junk = Buffer.from('BM-stock'); + expect(bmpWithExplicitAlpha(junk)).toBe(junk); + }); +}); + describe('groupCursorFiles', () => { it('treats one folder of cursors as a single set', () => { const groups = groupCursorFiles([ diff --git a/electron/main/services/cursorFiles.ts b/electron/main/services/cursorFiles.ts index 6562ef2b..ebf5e858 100644 --- a/electron/main/services/cursorFiles.ts +++ b/electron/main/services/cursorFiles.ts @@ -21,6 +21,45 @@ export function isUsableCursorFile(fileName: string, bytes: Uint8Array): boolean return bytes[0] === 0x42 && bytes[1] === 0x4d; } +const FILE_HEADER = 14; +const V5_HEADER = 124; + +/** + * The stock cursors are 32-bit BI_RGB BMPs whose reserved fourth byte is alpha. + * SDL reads it, but Chromium treats it as padding and paints the transparent + * pixels (stored white) opaque. Re-header those as BITMAPV5 with an explicit + * alpha mask so a preview matches the game. Anything else passes through. + */ +export function bmpWithExplicitAlpha(bytes: Buffer): Buffer { + if (bytes.length < FILE_HEADER + 40) return bytes; + if (bytes.readUInt32LE(14) !== 40 || bytes.readUInt16LE(28) !== 32 || bytes.readUInt32LE(30) !== 0) return bytes; + const pixels = bytes.subarray(bytes.readUInt32LE(10)); + // SDL loads an all-zero alpha channel as opaque, which the file already previews as. + let hasAlpha = false; + for (let i = 3; i < pixels.length; i += 4) { + if (pixels[i] !== 0) { + hasAlpha = true; + break; + } + } + if (!hasAlpha) return bytes; + + const offset = FILE_HEADER + V5_HEADER; + const out = Buffer.alloc(offset + pixels.length); + bytes.copy(out, 0, 0, FILE_HEADER + 40); + out.writeUInt32LE(out.length, 2); + out.writeUInt32LE(offset, 10); + out.writeUInt32LE(V5_HEADER, 14); + out.writeUInt32LE(3, 30); // BI_BITFIELDS + out.writeUInt32LE(0x00ff0000, 54); + out.writeUInt32LE(0x0000ff00, 58); + out.writeUInt32LE(0x000000ff, 62); + out.writeUInt32LE(0xff000000, 66); + out.write('BGRs', 70, 'latin1'); // LCS_sRGB, little-endian + pixels.copy(out, offset); + return out; +} + export interface CursorSourceFile { path: string; fileName: string; diff --git a/electron/main/services/cursorPacks.ts b/electron/main/services/cursorPacks.ts index 7a0053e2..9b4834df 100644 --- a/electron/main/services/cursorPacks.ts +++ b/electron/main/services/cursorPacks.ts @@ -3,7 +3,7 @@ import { existsSync, promises as fs } from 'fs'; import { basename, join } from 'path'; import type { CursorPack, CursorPacksState, CursorPreview } from '../../../src/types/electron'; import { getUserDataPath } from '../utils/paths'; -import { groupCursorFiles, isCursorFileName, isUsableCursorFile } from './cursorFiles'; +import { bmpWithExplicitAlpha, groupCursorFiles, isCursorFileName, isUsableCursorFile } from './cursorFiles'; import { getCitadelPath } from './deadlock'; import { extractArchive, type ExtractedVpk } from './extract'; @@ -173,7 +173,7 @@ async function readPreviewDir(dir: string, names: readonly string[]): Promise( () => firstPopulated[0] ?? 'soul-container' ); - // Tab order: the seven classification types, then Global (the precedence - // axis). Global is deliberately last and visually separated: it answers a - // different question ("does this mod win?") than the types above it ("what - // kind of mod is this?"). The user's own categories follow, behind a second - // separator, as a third axis again ("which pile did I put it in?"). + // Tab order: the classification types, Cursors, then Global (the precedence + // axis), with populated tabs pulled above empty ones so a mostly-empty rail + // leads with what is installed. The user's own categories follow, behind a + // separator, in their own order ("which pile did I put it in?"). const cursorPackCount = useCursorPackStore((s) => s.packs.length); - const fixedTabIds: readonly GeneralTabId[] = [...GLOBAL_MOD_TYPE_ORDER, CURSOR_TAB, PRIORITY_TAB]; const countForTab = (tab: GeneralTabId) => { if (tab === PRIORITY_TAB) return priorityMods.length; if (tab === CURSOR_TAB) return cursorPackCount; if (isCustomTab(tab)) return categoryGroups.get(customTabCategoryId(tab))?.length ?? 0; return groups[tab].length; }; + const builtinTabIds: readonly GeneralTabId[] = [...GLOBAL_MOD_TYPE_ORDER, CURSOR_TAB, PRIORITY_TAB]; + const fixedTabIds = [ + ...builtinTabIds.filter((tab) => countForTab(tab) > 0), + ...builtinTabIds.filter((tab) => countForTab(tab) === 0), + ]; + const fixedTabOrder = fixedTabIds.join(); // Sliding active-tab highlight, mirroring the main sidebar's glide: one // indicator element animates between the tab rows rather than each row // toggling its own background (which snaps). Refs feed its measured position. @@ -2006,12 +2010,12 @@ function LockerGlobalView({ groups, hideNsfw, onBack, onToggle, onSetGlobalType, }, [selectedType, categories, landingTab]); // Track the active row's box so the highlight can glide to it. Measured in a // layout effect (pre-paint) to avoid a one-frame jump on first mount. Also - // re-measured when the category count changes: adding or deleting one moves - // the rows below it without the active tab changing. + // re-measured when the rows move without the active tab changing: a category + // added or deleted, or a tab filling up or emptying out. useLayoutEffect(() => { const el = tabRefs.current.get(activeType); if (el) setTabIndicator({ top: el.offsetTop, height: el.offsetHeight }); - }, [activeType, categories.length]); + }, [activeType, categories.length, fixedTabOrder]); // Soul containers and spirit urns share the single-select + live-3D-tile // treatment (frosted glass, content-stable key, active badge, import button). // Never true for the Global tab: priority mods are ordinary multi-toggle