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
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,16 @@ native `<input type=color>`, no Acrylic or Accent opacity slider. Spec and plan:
over. Marks elsewhere are dropped for the same reason: right-clicking row A while B and C
are marked leaves the verb going to A, and marks claiming otherwise are lying. Right-
clicking INSIDE a multi-selection still acts on all of it.
- **THE DRAG LABEL HANGS OFF THE POINTER'S BOTTOM RIGHT, ATTACHED** (#310; owner, 2026-10-07: "when
you pick up an item with left click drag the label is to its bottom left but also not attached to
the cursor ... it should be attached and it should be from the bottom right"). The label is an
IN-PAGE element (`lib/internalFileDrag.ts`, which cancels the native drag so Ctrl+Tab still works),
not a drag image, so it is placed in CSS px from the pointer and the display scale never enters.
It sat its whole width LEFT of the pointer and 12 px below. `lib/dragBadgePlace.ts` puts its
top-left corner 8 px right of and below the tip (File Explorer's way: the pointer never covers the
name), flipping to the other side only where the window edge leaves no room. One rule for every
drag the hook carries: tree, Explorer list, several items, places. The `dragLabel` e2e measures it
mid-drag at 100%, 150% and 225% (`--force-device-scale-factor`).
- **SETTINGS CONTROLS ARE NEUTRAL; ONLY SAVE WEARS THE ACCENT** (#202; owner, 2026-09-23: "i dont
want settings buttons to be affected by the accent colour... grey based on the bg colour ... same
colours as the drop down menus"; "the only ones to keep accented are the save buttons"). The
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "prism",
"version": "0.94.0",
"version": "0.94.1",
"description": "A fast, universal media viewer for Windows.",
"author": "Max",
"license": "MIT",
Expand Down
32 changes: 32 additions & 0 deletions src/renderer/src/lib/dragBadgePlace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { DRAG_BADGE_GAP, dragBadgePlace } from './dragBadgePlace'

describe('dragBadgePlace', () => {
it('hangs the label off the bottom right of the pointer, a small gap away', () => {
expect(dragBadgePlace(300, 200, 120, 34, 1200, 800)).toEqual({
left: 300 + DRAG_BADGE_GAP,
top: 200 + DRAG_BADGE_GAP
})
})
it('the gap is a few pixels, not the 10 to 20 the owner saw', () => {
expect(DRAG_BADGE_GAP).toBeGreaterThanOrEqual(4)
expect(DRAG_BADGE_GAP).toBeLessThanOrEqual(8)
})
it('never puts the label left of the pointer while there is room on the right', () => {
const { left } = dragBadgePlace(50, 50, 200, 34, 1200, 800)
expect(left).toBeGreaterThan(50)
})
it('flips to the left near the right edge instead of sliding under the pointer', () => {
const { left } = dragBadgePlace(1150, 200, 120, 34, 1200, 800)
expect(left + 120).toBe(1150 - DRAG_BADGE_GAP)
})
it('flips above near the bottom edge', () => {
const { top } = dragBadgePlace(300, 790, 120, 34, 1200, 800)
expect(top + 34).toBe(790 - DRAG_BADGE_GAP)
})
it('a label too wide for either side stays inside the window', () => {
const { left } = dragBadgePlace(200, 100, 380, 34, 400, 800)
expect(left).toBeGreaterThanOrEqual(4)
expect(left + 380).toBeLessThanOrEqual(400)
})
})
34 changes: 34 additions & 0 deletions src/renderer/src/lib/dragBadgePlace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Where the drag label sits while a file is carried (#310; owner, 2026-10-07:
* "it should be attached and it should be from the bottom right").
*
* The label is an in-page element placed in CSS pixels from the pointer's
* client coordinates, so the display scale does not enter into it: the same
* numbers hold at 100%, 150% and 225%. Its top-left corner hangs a small gap
* below and right of the cursor's tip, as File Explorer's does, so the pointer
* never covers the name. Only where the window's edge leaves no room does it
* flip to the other side of the pointer, never sliding under it.
*/
export const DRAG_BADGE_GAP = 8
/** How far the label keeps from the window's edges. */
export const DRAG_BADGE_MARGIN = 4

export function dragBadgePlace(
x: number,
y: number,
width: number,
height: number,
viewWidth: number,
viewHeight: number
): { left: number; top: number } {
const side = (at: number, size: number, view: number): number => {
const after = at + DRAG_BADGE_GAP
if (after + size <= view - DRAG_BADGE_MARGIN) return after
const before = at - DRAG_BADGE_GAP - size
if (before >= DRAG_BADGE_MARGIN) return before
// No room on either side (a label wider than half the window): keep it
// inside the window, which is all that is left to promise.
return Math.max(DRAG_BADGE_MARGIN, view - DRAG_BADGE_MARGIN - size)
}
return { left: side(x, width, viewWidth), top: side(y, height, viewHeight) }
}
9 changes: 6 additions & 3 deletions src/renderer/src/lib/internalFileDrag.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
import { dragBadgePlace } from './dragBadgePlace'
import { DRAG_MIME, getDrag, setDrag } from './dragDrop'
import { QUICK_ACCESS_PIN_MIME } from './quickAccess'

Expand Down Expand Up @@ -63,9 +64,11 @@ export function useInternalFileDrag(stepTab: (delta: number) => void): void {
const action = effect === 'move' ? 'Move' : effect === 'copy' ? 'Copy' : ''
carry.badge.textContent = action ? `${action} ${carry.label}` : carry.label
const { width, height } = carry.badge.getBoundingClientRect()
// Hang the label below and left of the hand, keeping its drop target clear.
carry.badge.style.left = `${Math.max(4, Math.min(carry.x - width - 4, window.innerWidth - width - 4))}px`
carry.badge.style.top = `${Math.max(4, Math.min(carry.y + 12, window.innerHeight - height - 4))}px`
// Hang the label off the pointer's bottom right, attached (#310). It
// used to sit its whole width LEFT of the pointer and 12 px below it.
const place = dragBadgePlace(carry.x, carry.y, width, height, window.innerWidth, window.innerHeight)
carry.badge.style.left = `${place.left}px`
carry.badge.style.top = `${place.top}px`
}
const tick = (): void => {
if (!carry) return
Expand Down
100 changes: 100 additions & 0 deletions tools/e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11199,6 +11199,105 @@ async function styleColoursScenario(fixtures) {
}
}

async function dragLabelScenario(fixtures) {
console.log('the drag label hangs off the cursor')
// #310 (owner, 2026-10-07): "when you pick up an item with left click drag
// the label is to its bottom left but also not attached to the cursor ...
// it should be attached and it should be from the bottom right". The label
// is an in-page element (internalFileDrag), so its box is read mid-drag
// against the pointer, in CSS pixels, at three display scales. A REAL press
// and travel, so the hook's own dragstart takes the drag.
const dir = join(fixtures, 'draglabel')
rmSync(dir, { recursive: true, force: true })
mkdirSync(join(dir, 'folder'), { recursive: true })
for (const n of ['alpha.txt', 'beta.txt']) writeFileSync(join(dir, n), `drag label ${n}\n`)
const badge = (win) =>
win.evaluate(() => {
const el = document.querySelector('[data-file-drag-badge]')
const r = el?.getBoundingClientRect()
return r ? { left: r.left, top: r.top, width: r.width, text: el.textContent ?? '', dpr: devicePixelRatio } : null
})
/** Press on `el`, travel to two places, read the label at each, let go with Escape. */
const carry = async (win, el, what, shot) => {
const box = await el.boundingBox()
if (!box) {
ok(false, `${what}: the row is on screen`)
return null
}
const from = { x: Math.round(box.x + Math.min(24, box.width / 2)), y: Math.round(box.y + box.height / 2) }
await win.mouse.move(from.x, from.y)
await win.mouse.down()
const seen = []
for (const [dx, dy] of [[70, 50], [140, 90]]) {
const at = { x: from.x + dx, y: from.y + dy }
await win.mouse.move(at.x, at.y, { steps: 8 })
await sleep(120)
const b = await badge(win)
seen.push(b && { ...b, x: at.x, y: at.y, offX: b.left - at.x, offY: b.top - at.y })
}
if (shot) await win.screenshot({ path: join(SHOTS, shot) })
await win.keyboard.press('Escape')
await win.mouse.up()
await sleep(200)
const [a, b] = seen
ok(!!a && !!b, `${what}: a label is carried (${a?.text ?? 'none'})`)
if (!a || !b) return null
const near = (o) => o >= 4 && o <= 8
ok(
near(a.offX) && near(a.offY),
`${what}: its top-left corner sits 4 to 8 px right of and below the pointer (${a.offX}, ${a.offY} at dpr ${a.dpr})`
)
ok(Math.abs(a.offX - b.offX) < 0.5 && Math.abs(a.offY - b.offY) < 0.5, `${what}: and stays attached as the pointer travels (${b.offX}, ${b.offY})`)
ok(!(await badge(win)), `${what}: and goes when the drag ends`)
return a
}
for (const scale of [1, 1.5, 2.25]) {
// Forced at 100% too: this machine runs at 225%, and an unforced window
// would measure that twice.
EXTRA_ARGS = [`--force-device-scale-factor=${scale}`]
const { app, win } = await launch(join(dir, 'alpha.txt'))
try {
await win.waitForSelector('aside [role="treeitem"]:has-text("beta.txt")', { timeout: 10000 })
await sleep(500)
const tree = (name) => win.locator(`aside [role="treeitem"]:has-text("${name}")`).first().locator('span.truncate').first()
const one = await carry(win, tree('beta.txt'), `${scale * 100}%, a file from the tree`, `drag-label-${scale * 100}.png`)
ok(!!one && Math.abs(one.dpr - scale) < 0.01, `the window really is at ${scale * 100}% (${one?.dpr})`)
ok(!!one && one.text.includes('beta.txt'), `and the label names the file (${one?.text})`)
if (scale !== 1) continue
await carry(win, tree('folder'), 'a folder from the tree')

/* ---------- the Explorer list ---------- */
await win.locator('[role="tablist"] [data-pinned] [role="tab"]').click()
await win.waitForSelector('[data-testid="browse-list"] .browse-row', { timeout: 10000 })
await win.locator('[data-testid="browse-list"] [data-browse-path$="draglabel"]').dblclick()
ok(
await until(async () => (await win.locator('[data-testid="browse-list"] [data-browse-path$=".txt"]').count()) === 2, 10000),
'the Explorer walked into the folder'
)
await sleep(400)
const row = (n) => win.locator(`[data-testid="browse-list"] [data-browse-path$="${n}"] .browse-name`).first()
await carry(win, row('alpha.txt'), 'a file from the Explorer list', 'drag-label-list.png')
await carry(win, row('folder'), 'a folder from the Explorer list')
await row('alpha.txt').click()
await win.keyboard.down('Control')
await row('beta.txt').click()
await win.keyboard.up('Control')
await sleep(200)
const many = await carry(win, row('beta.txt'), 'two marked items from the Explorer list', 'drag-label-many.png')
ok(!!many && /2 items/.test(many.text), `and the label counts them (${many?.text})`)

/* ---------- the places panel ---------- */
const pin = win.locator('.quick-access-pin').first()
if (await pin.count()) await carry(win, pin, 'a place from the places panel')
else ok(false, 'the places panel shows a place to drag')
} finally {
await app.close()
EXTRA_ARGS = []
}
await sleep(900)
}
}

async function dragScenario(fixtures) {
console.log('drag and drop')
// #70: a row dragged onto a folder MOVES; a member dragged out of an archive
Expand Down Expand Up @@ -16026,6 +16125,7 @@ await run(accentOpacityScenario)
await run(styleColoursScenario)
await run(seeThroughScenario)
await run(dragScenario)
await run(dragLabelScenario)
await run(marqueeScenario)
await run(marqueeQuietScenario)
await run(markTintScenario)
Expand Down
Loading