From f865519073060407a2dfa89e0082692a1ddb2477 Mon Sep 17 00:00:00 2001 From: wkotheimer Date: Fri, 11 Sep 2026 21:20:50 -0500 Subject: [PATCH 1/2] Give the Store package its reserved identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The appx block held placeholders, and one of them could not even be built: identityName forbids underscores, so `npm run package:store` failed at the last step with the config as checked in. The values now come from the Partner Center reservation. "Spool" was taken, so the reserved name — which the Store requires the package's display name to match — is "Spool Clipboard". The app itself still calls itself Spool; the longer name is a Store handle, not a rebrand. Co-Authored-By: Claude Opus 5 --- electron-builder.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index ce811ea..253d989 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -62,13 +62,15 @@ nsis: uninstallDisplayName: Spool # The Microsoft Store target. The identity values come from the app's reservation in Partner -# Center and cannot be guessed — build with `npm run package:store` once they are filled in. +# Center (Store ID 9N7J7LM83RM5) — build with `npm run package:store`. appx: applicationId: Spool - displayName: Spool - publisherDisplayName: PUBLISHER_DISPLAY_NAME_FROM_PARTNER_CENTER - identityName: IDENTITY_NAME_FROM_PARTNER_CENTER - publisher: CN=PUBLISHER_ID_FROM_PARTNER_CENTER + # The Store requires the package's display name to match the name reserved in Partner Center, + # which is "Spool Clipboard" — plain "Spool" was not available. + displayName: Spool Clipboard + publisherDisplayName: Will Kotheimer + identityName: WillKotheimer.SpoolClipboard + publisher: CN=719AD8B0-98A9-4244-B722-B46BA1A544C3 backgroundColor: '#171614' # Spool is a desktop app with a native clipboard listener and a compiled database module, so it # needs full trust rather than the sandboxed app container. From a36cbd6c5b6207606f652dc926d9c3310c88e588 Mon Sep 17 00:00:00 2001 From: wkotheimer Date: Fri, 11 Sep 2026 21:20:51 -0500 Subject: [PATCH 2/2] Choose clips by clicking the row, not a checkbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checkbox was the wrong control. Every list on the desktop already teaches how to choose things in it — click for one, Ctrl-click to add or drop one, Shift-click for a run — and a column of boxes asked the user to learn a second grammar for the same act, and to aim at a small target to do it. The row is now the control, and a chosen row is lit rather than ticked. Nothing is lit when nothing is chosen. The boxes had shown every clip ticked in that state, because an empty selection means every clip; but a lit row says a choice has been made, and lighting them all would say so when none has. So the list is neutral until it is narrowed, and then the clips left out recede rather than vanish — they are still in the spool, just not in play. The model underneath is unchanged. `only` and `range` join `toggle` as the three ways a click can change the set; the session keeps the anchor a Shift-click runs from, and forgets it wherever it forgets the selection, so the two cannot disagree. Clicking the one chosen clip again clears, as does Escape and a line under the list, all meaning the same thing: back to all. Co-Authored-By: Claude Opus 5 --- PLAN.md | 12 ++- src/main/core/selection.test.ts | 38 ++++++++- src/main/core/selection.ts | 28 ++++++ src/main/ipc/index.ts | 8 +- src/main/session.test.ts | 95 ++++++++++++++++++--- src/main/session.ts | 46 ++++++++-- src/preload/index.ts | 7 +- src/renderer/components/ClipList.tsx | 82 +++++++++++++----- src/renderer/components/ExpandedView.tsx | 2 +- src/renderer/env.d.ts | 10 ++- src/renderer/helpers/ClipListHelper.test.ts | 40 ++++++++- src/renderer/helpers/ClipListHelper.ts | 24 +++++- src/shared/ipc.ts | 9 +- 13 files changed, 346 insertions(+), 55 deletions(-) diff --git a/PLAN.md b/PLAN.md index fd84044..19f2585 100644 --- a/PLAN.md +++ b/PLAN.md @@ -130,14 +130,22 @@ arrange them, and put them back into one document in the right order. ### Choosing which clips are in play -A spool is not always wanted whole. Ticking clips narrows what the next serve delivers and what the +A spool is not always wanted whole. Choosing clips narrows what the next serve delivers and what the whole-spool paste joins — the same working set for both, so `Win+Alt+U`, `Win+Alt+V` and the button can never mean different things. +**The row is the control.** Click a clip to choose it alone, Ctrl-click to add or drop one, +Shift-click for the run from the last click — the gestures every list on the desktop already +teaches, so there is nothing to learn and no checkbox to aim at. Chosen rows are lit and the rest +recede, which makes a narrowed spool look narrowed; with nothing chosen nothing is lit, because +lighting every row would say a choice had been made when none has. + **An empty selection means every clip.** Selecting nothing and meaning nothing is not a state worth having: it would make both hotkeys dead and the button a no-op, with nothing to distinguish that from a bug. So clearing the selection and selecting everything are the same act, and the app offers -one command rather than two that disagree at the edges. Unticking the last box returns to all. +one command rather than two that disagree at the edges. Clicking the one chosen clip again, +Ctrl-clicking the last one out, pressing Escape, and the **Select all** line under the list all do +the same thing: back to all. The selection is **not stored**. It describes what you are doing now, the way a text selection does, and one that survived a restart would be a rule the user does not remember making. It ends when the diff --git a/src/main/core/selection.test.ts b/src/main/core/selection.test.ts index f89f3a4..b4eb6a6 100644 --- a/src/main/core/selection.test.ts +++ b/src/main/core/selection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isSelected, prune, selectedClips, selectedCount, toggle } from './selection' +import { isSelected, only, prune, range, selectedClips, selectedCount, toggle } from './selection' import type { Clip } from './types' const clip = (id: string): Clip => ({ @@ -60,3 +60,39 @@ describe('prune', () => { expect(selectedCount([clip('a')], prune([clip('a')], new Set(['gone'])))).toBe(1) }) }) + +describe('only', () => { + it('chooses one clip and drops the rest', () => { + expect(only(new Set(['a', 'b']), 'c')).toEqual(new Set(['c'])) + }) + + it('choosing the sole chosen clip again clears, which means all', () => { + expect(only(new Set(['a']), 'a')).toEqual(new Set()) + }) + + it('choosing one of several chosen narrows to it rather than clearing', () => { + expect(only(new Set(['a', 'b']), 'a')).toEqual(new Set(['a'])) + }) +}) + +describe('range', () => { + it('takes the run from the anchor to the clip, inclusive', () => { + expect(range(clips, new Set(), 'a', 'c')).toEqual(new Set(['a', 'b', 'c'])) + }) + + it('reads the same run whichever end was clicked first', () => { + expect(range(clips, new Set(), 'c', 'a')).toEqual(new Set(['a', 'b', 'c'])) + }) + + it('replaces what was chosen before, as shift-click does everywhere', () => { + expect(range(clips, new Set(['c']), 'a', 'b')).toEqual(new Set(['a', 'b'])) + }) + + it('is a plain click when there is no anchor', () => { + expect(range(clips, new Set(['a']), null, 'b')).toEqual(new Set(['b'])) + }) + + it('is a plain click when the anchor has left the spool', () => { + expect(range(clips, new Set(), 'gone', 'b')).toEqual(new Set(['b'])) + }) +}) diff --git a/src/main/core/selection.ts b/src/main/core/selection.ts index 4bdb41d..5569adc 100644 --- a/src/main/core/selection.ts +++ b/src/main/core/selection.ts @@ -49,3 +49,31 @@ export function toggle(selection: ReadonlySet, clipId: string): Set, clipId: string): Set { + if (selection.size === 1 && selection.has(clipId)) return new Set() + return new Set([clipId]) +} + +/** + * Choose the run from the anchor to this clip, inclusive, in spool order — whichever way round + * they were clicked. The run replaces what was chosen before, as Shift-click does everywhere + * else; a Shift-click with no anchor to run from is a plain click. + */ +export function range( + clips: readonly Clip[], + selection: ReadonlySet, + anchorId: string | null, + clipId: string +): Set { + const from = anchorId === null ? -1 : clips.findIndex((clip) => clip.id === anchorId) + const to = clips.findIndex((clip) => clip.id === clipId) + if (from === -1 || to === -1) return only(selection, clipId) + + const [start, end] = from <= to ? [from, to] : [to, from] + return new Set(clips.slice(start, end + 1).map((clip) => clip.id)) +} diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index e62fbb0..c2db49b 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -6,7 +6,7 @@ import { type SeparatorKind, type WindowStateName } from '../../shared/ipc' -import type { HotkeyAction } from '../../shared/ipc' +import type { HotkeyAction, SelectGesture } from '../../shared/ipc' import type { Session } from '../session' /** @@ -85,8 +85,8 @@ export function registerIpc( ipcMain.handle(CHANNELS.acknowledgePrivacy, () => actions.acknowledgePrivacy()) ipcMain.handle(CHANNELS.toggleMode, () => session.toggleMode()) ipcMain.handle(CHANNELS.setAutoPaste, (_event, enabled: boolean) => session.setAutoPaste(enabled)) - ipcMain.handle(CHANNELS.toggleClipSelected, (_event, clipId: string) => - session.toggleClipSelected(clipId) + ipcMain.handle(CHANNELS.selectClip, (_event, clipId: string, gesture: SelectGesture) => + session.selectClip(clipId, gesture) ) ipcMain.handle(CHANNELS.selectAllClips, () => session.selectAllClips()) ipcMain.handle(CHANNELS.setHotkey, (_event, action: HotkeyAction, accelerator: string) => @@ -133,7 +133,7 @@ export function registerIpc( ipcMain.removeHandler(CHANNELS.acknowledgePrivacy) ipcMain.removeHandler(CHANNELS.toggleMode) ipcMain.removeHandler(CHANNELS.setAutoPaste) - ipcMain.removeHandler(CHANNELS.toggleClipSelected) + ipcMain.removeHandler(CHANNELS.selectClip) ipcMain.removeHandler(CHANNELS.selectAllClips) ipcMain.removeHandler(CHANNELS.setHotkey) ipcMain.removeHandler(CHANNELS.resetHotkey) diff --git a/src/main/session.test.ts b/src/main/session.test.ts index 1ef67e0..ea9562a 100644 --- a/src/main/session.test.ts +++ b/src/main/session.test.ts @@ -802,8 +802,8 @@ describe('choosing which clips are in play (PLAN.md 3)', () => { it('steps over the clips that were not chosen', () => { const { session, written } = withClips('one', 'two', 'three') const [first, , third] = ids(session) - session.toggleClipSelected(first) - session.toggleClipSelected(third) + session.selectClip(first, 'toggle') + session.selectClip(third, 'toggle') session.serveNext() session.serveNext() @@ -814,8 +814,8 @@ describe('choosing which clips are in play (PLAN.md 3)', () => { it('wraps among the chosen clips rather than through the others', () => { const { session, written } = withClips('one', 'two', 'three') const [first, , third] = ids(session) - session.toggleClipSelected(first) - session.toggleClipSelected(third) + session.selectClip(first, 'toggle') + session.selectClip(third, 'toggle') session.serveNext() session.serveNext() @@ -827,8 +827,8 @@ describe('choosing which clips are in play (PLAN.md 3)', () => { it('joins only the chosen clips, in spool order', () => { const { session, written } = withClips('one', 'two', 'three') const [first, , third] = ids(session) - session.toggleClipSelected(third) - session.toggleClipSelected(first) + session.selectClip(third, 'toggle') + session.selectClip(first, 'toggle') session.pasteWholeSpool() @@ -840,7 +840,7 @@ describe('choosing which clips are in play (PLAN.md 3)', () => { it('serves a chosen clip even when the cursor sat on one that was not', () => { const { session, written } = withClips('one', 'two', 'three') // The cursor is on 'one'; choosing only the third must not leave serving stuck. - session.toggleClipSelected(ids(session)[2]) + session.selectClip(ids(session)[2], 'toggle') session.serveNext() @@ -850,10 +850,10 @@ describe('choosing which clips are in play (PLAN.md 3)', () => { it('unticking the last clip returns to all, rather than to none', () => { const { session, written } = withClips('one', 'two') const [first] = ids(session) - session.toggleClipSelected(first) + session.selectClip(first, 'toggle') expect(session.getState().spool.inPlay).toBe(1) - session.toggleClipSelected(first) + session.selectClip(first, 'toggle') expect(session.getState().spool.hasSelection).toBe(false) session.pasteWholeSpool() @@ -862,7 +862,7 @@ describe('choosing which clips are in play (PLAN.md 3)', () => { it('selecting all again is the way back, and costs nothing when already there', () => { const { session } = withClips('one', 'two') - session.toggleClipSelected(ids(session)[0]) + session.selectClip(ids(session)[0], 'toggle') session.selectAllClips() @@ -873,7 +873,7 @@ describe('choosing which clips are in play (PLAN.md 3)', () => { it('forgets the choice when the clips it named are deleted', () => { const { session } = withClips('one', 'two') const [first] = ids(session) - session.toggleClipSelected(first) + session.selectClip(first, 'toggle') session.deleteClip(first) @@ -886,6 +886,79 @@ describe('choosing which clips are in play (PLAN.md 3)', () => { const { session } = withClips('one', 'two') expect(session.getState().spool.clips.every((c) => c.isSelected)).toBe(true) }) + + it('a plain click chooses that clip alone, whatever was chosen before', () => { + const { session, written } = withClips('one', 'two', 'three') + const [first, second] = ids(session) + session.selectClip(first, 'toggle') + session.selectClip(second, 'toggle') + + session.selectClip(second, 'only') + + expect(session.getState().spool.inPlay).toBe(1) + session.pasteWholeSpool() + expect(written).toEqual(['two']) + }) + + it('clicking the one chosen clip again is the way back to all', () => { + const { session } = withClips('one', 'two') + const [first] = ids(session) + session.selectClip(first, 'only') + + session.selectClip(first, 'only') + + expect(session.getState().spool.hasSelection).toBe(false) + }) + + it('a shift-click takes the run from the last click, in spool order either way round', () => { + const { session, written } = withClips('one', 'two', 'three', 'four') + const [, second, , fourth] = ids(session) + session.selectClip(fourth, 'only') + + session.selectClip(second, 'range') + + expect(session.getState().spool.inPlay).toBe(3) + session.pasteWholeSpool() + expect(written).toEqual(['two\nthree\nfour']) + }) + + it('a second shift-click moves the far end of the same run', () => { + const { session, written } = withClips('one', 'two', 'three', 'four') + const [first, second, , fourth] = ids(session) + session.selectClip(first, 'only') + session.selectClip(fourth, 'range') + + session.selectClip(second, 'range') + + session.pasteWholeSpool() + expect(written).toEqual(['one\ntwo']) + }) + + it('a shift-click with nothing to run from is a plain click', () => { + const { session } = withClips('one', 'two', 'three') + session.selectClip(ids(session)[2], 'range') + + expect(session.getState().spool.inPlay).toBe(1) + }) + + it('the run starts again from wherever the selection was last cleared', () => { + const { session } = withClips('one', 'two', 'three') + const [first, , third] = ids(session) + session.selectClip(first, 'only') + session.selectAllClips() + + // The anchor went with the selection: this run has no start, so it is a single choice. + session.selectClip(third, 'range') + + expect(session.getState().spool.inPlay).toBe(1) + }) + + it('ignores a click on a clip that is not in this spool', () => { + const { session } = withClips('one') + session.selectClip('not-a-clip', 'only') + + expect(session.getState().spool.hasSelection).toBe(false) + }) }) describe('arranging (PLAN.md 11, M7)', () => { diff --git a/src/main/session.ts b/src/main/session.ts index 1f0080d..ad2179f 100644 --- a/src/main/session.ts +++ b/src/main/session.ts @@ -5,6 +5,7 @@ import type { HotkeyView, Notice, PendingPrompt, + SelectGesture, StorageStatus } from '../shared/ipc' import { @@ -35,7 +36,7 @@ import { type MeasureName } from './core/capacity' import { expireClips, isRetentionHours } from './core/retention' -import { prune, toggle } from './core/selection' +import { only, prune, range, toggle } from './core/selection' import { arrange, clear, createSpool, deleteClip, serve, setMode } from './core/spool' import type { Clip, Mode, Spool } from './core/types' import type { ClipboardSnapshot } from './detect/admit' @@ -157,6 +158,12 @@ export class Session { */ private selection: ReadonlySet = new Set() + /** + * The clip a Shift-click runs from: the one last chosen on its own or toggled. It lives and dies + * with the selection, so nothing here has to remember to reset it separately. + */ + private selectionAnchor: string | null = null + constructor( private readonly writeText: (text: string) => void, /** @@ -186,9 +193,28 @@ export class Session { this.publish() } - /** Put one clip in or out of the working set. */ - toggleClipSelected(clipId: string): void { - this.selection = toggle(this.selection, clipId) + /** + * Change which clips are in play with one click (PLAN.md 3). A plain click chooses that clip + * alone, Ctrl adds or removes it, Shift takes the run from the last plain or Ctrl click. + */ + selectClip(clipId: string, gesture: SelectGesture): void { + if (!this.state.spool.clips.some((clip) => clip.id === clipId)) return + + switch (gesture) { + case 'only': + this.selection = only(this.selection, clipId) + this.selectionAnchor = clipId + break + case 'toggle': + this.selection = toggle(this.selection, clipId) + this.selectionAnchor = clipId + break + case 'range': + this.selection = range(this.state.spool.clips, this.selection, this.selectionAnchor, clipId) + // The anchor stays put, so a second Shift-click adjusts the far end of the same run. + if (this.selectionAnchor === null) this.selectionAnchor = clipId + break + } this.publish() } @@ -198,10 +224,15 @@ export class Session { */ selectAllClips(): void { if (this.selection.size === 0) return - this.selection = new Set() + this.forgetSelection() this.publish() } + private forgetSelection(): void { + this.selection = new Set() + this.selectionAnchor = null + } + setAutoPaste(enabled: boolean): void { this.autoPaste = enabled this.publish() @@ -621,6 +652,7 @@ export class Session { // A selection holding a clip that is gone would keep counting it, so the button would promise // more than it can deliver. this.selection = prune(this.state.spool.clips, this.selection) + if (this.selectionAnchor === clipId) this.selectionAnchor = null this.publish() } @@ -631,7 +663,7 @@ export class Session { // The next copy is not a duplicate of something that is no longer there. this.state = { ...this.state, lastCapturedText: null } // Every clip the working set named has gone with them. - this.selection = new Set() + this.forgetSelection() } else { this.otherSpools = this.otherSpools.map((spool) => spool.id === spoolId ? clear(spool) : spool @@ -867,7 +899,7 @@ export class Session { this.otherSpools = keepLeaving ? [...remaining, leaving] : remaining // The working set named clips in the spool being left, so it ends with it. Carrying it across // would leave a selection the user cannot see and did not make here. - this.selection = new Set() + this.forgetSelection() // Duplicate suppression compares against the last capture *in this spool*, so it resets. this.state = { ...this.state, spool: this.touch(next), lastCapturedText: null } this.savedSpool = null diff --git a/src/preload/index.ts b/src/preload/index.ts index 09d5dd1..095de18 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,6 +5,7 @@ import { type AppState, type ConsentChoice, type HotkeyAction, + type SelectGesture, type SeparatorKind, type WindowStateName } from '../shared/ipc' @@ -71,9 +72,9 @@ const api = { setAutoPaste: (enabled: boolean): Promise => ipcRenderer.invoke(CHANNELS.setAutoPaste, enabled), - /** Put one clip in or out of the working set (PLAN.md 3). */ - toggleClipSelected: (clipId: string): Promise => - ipcRenderer.invoke(CHANNELS.toggleClipSelected, clipId), + /** Change which clips are in play with one click (PLAN.md 3); the gesture says how. */ + selectClip: (clipId: string, gesture: SelectGesture): Promise => + ipcRenderer.invoke(CHANNELS.selectClip, clipId, gesture), /** Back to every clip. Clearing a selection and selecting all are the same act. */ selectAllClips: (): Promise => ipcRenderer.invoke(CHANNELS.selectAllClips), diff --git a/src/renderer/components/ClipList.tsx b/src/renderer/components/ClipList.tsx index fba77d5..3198316 100644 --- a/src/renderer/components/ClipList.tsx +++ b/src/renderer/components/ClipList.tsx @@ -1,15 +1,31 @@ -import type { JSX } from 'react' +import { useEffect, type JSX, type KeyboardEvent, type MouseEvent } from 'react' import type { SpoolView } from '../../shared/ipc' -import { clipRows, hiddenCounts, sourceLabel } from '../helpers/ClipListHelper' +import { clipRows, gestureFor, hiddenCounts, inPlayLabel, sourceLabel } from '../helpers/ClipListHelper' /** * The clips in the active spool, oldest first, with the next one to serve marked (PLAN.md 8). * The marker is the point: the state of the spool has to be legible without opening anything. + * + * Each row is also the way to choose which clips are in play (PLAN.md 3). The row itself is the + * control — click to choose one, Ctrl-click to add or drop one, Shift-click for a run — the way + * every list on the desktop already works, so there is nothing to learn and no checkbox to aim at. + * Chosen rows are lit and the rest recede, so a narrowed spool looks narrowed. */ export function ClipList({ spool }: { spool: SpoolView }): JSX.Element { const rows = clipRows(spool) const hidden = hiddenCounts(spool) + // Escape is the way out that needs no aim. Bound to the window rather than the list so it works + // wherever focus happens to be, and only while there is a selection to leave. + useEffect(() => { + if (!spool.hasSelection) return + const onKeyDown = (event: globalThis.KeyboardEvent): void => { + if (event.key === 'Escape') void window.spool.selectAllClips() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [spool.hasSelection]) + if (rows.length === 0) { return (
@@ -20,36 +36,48 @@ export function ClipList({ spool }: { spool: SpoolView }): JSX.Element { ) } + const select = (clipId: string, event: MouseEvent | KeyboardEvent): void => { + void window.spool.selectClip(clipId, gestureFor(event)) + } + // The elided lines sit outside the scrolling list on purpose: a note about what is off-screen is // useless if reading it requires scrolling to the place it is describing. return (
{hidden.above > 0 && } -
    +
      {rows.map(({ clip, position, isNext }) => { const source = sourceLabel(clip) + // With nothing chosen every clip is in play, but lighting them all would say a choice had + // been made. So the list is only lit once it is narrowed, and then what is out recedes. + const chosen = spool.hasSelection && clip.isSelected + const out = spool.hasSelection && !clip.isSelected return (
    1. select(clip.id, event)} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + select(clip.id, event) + }} + className={[ + 'cursor-pointer rounded border px-2 py-1.5 select-none outline-none', + 'hover:bg-spool-paper/5 focus-visible:border-spool-paper/40', + isNext ? 'border-spool-thread/60' : 'border-transparent', + chosen ? 'bg-spool-paper/10' : isNext ? 'bg-spool-thread/10' : '', + out ? 'opacity-40' : '' + ].join(' ')} >
      - {/* - Checked means in play. With nothing chosen every box is checked, because an empty - selection means every clip — so the list never shows a state the hotkeys disagree - with (PLAN.md 3). - */} - void window.spool.toggleClipSelected(clip.id)} - aria-label={`Include ${clip.preview}`} - className="mt-0.5 shrink-0" - />
      {source !== null && ( - {source} + {source} )}
    2. ) })}
    {hidden.below > 0 && } + {spool.hasSelection && ( +

    + {inPlayLabel(spool)} + +

    + )}
) } diff --git a/src/renderer/components/ExpandedView.tsx b/src/renderer/components/ExpandedView.tsx index 424f850..50e25d3 100644 --- a/src/renderer/components/ExpandedView.tsx +++ b/src/renderer/components/ExpandedView.tsx @@ -147,7 +147,7 @@ export function ExpandedView({ )}

{spool.hasSelection - ? 'Ticked clips are the ones in play, for this button and for unspooling.' + ? 'Highlighted clips are the ones in play, for this button and for unspooling.' : 'Then paste once, with Ctrl+V. The cursor does not move.'}

diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 3a639a1..5b5e7ce 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -1,6 +1,12 @@ /// -import type { AppState, ConsentChoice, SeparatorKind, WindowStateName } from '../shared/ipc' +import type { + AppState, + ConsentChoice, + SelectGesture, + SeparatorKind, + WindowStateName +} from '../shared/ipc' declare global { interface SpoolApi { @@ -27,7 +33,7 @@ declare global { pauseCapture(): Promise acknowledgePrivacy(): Promise toggleMode(): Promise - toggleClipSelected(clipId: string): Promise + selectClip(clipId: string, gesture: SelectGesture): Promise selectAllClips(): Promise setAutoPaste(enabled: boolean): Promise setHotkey(action: HotkeyAction, accelerator: string): Promise diff --git a/src/renderer/helpers/ClipListHelper.test.ts b/src/renderer/helpers/ClipListHelper.test.ts index a283870..3fb85ed 100644 --- a/src/renderer/helpers/ClipListHelper.test.ts +++ b/src/renderer/helpers/ClipListHelper.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest' import type { ClipView, SpoolView } from '../../shared/ipc' -import { capacityLabel, clipRows, hiddenCounts, sourceLabel } from './ClipListHelper' +import { + capacityLabel, + clipRows, + gestureFor, + hiddenCounts, + inPlayLabel, + sourceLabel +} from './ClipListHelper' const clip = (id: string, sourceApp: string | null = null): ClipView => ({ id, @@ -108,3 +115,34 @@ describe('hiddenCounts', () => { expect(hiddenCounts(spool([], null))).toEqual({ above: 0, below: 0 }) }) }) + +describe('gestureFor', () => { + const keys = (held: Partial<{ shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }>) => ({ + shiftKey: false, + ctrlKey: false, + metaKey: false, + ...held + }) + + it('a bare click chooses one', () => { + expect(gestureFor(keys({}))).toBe('only') + }) + + it('ctrl adds or drops one, and cmd counts as ctrl', () => { + expect(gestureFor(keys({ ctrlKey: true }))).toBe('toggle') + expect(gestureFor(keys({ metaKey: true }))).toBe('toggle') + }) + + it('shift takes a run, even with ctrl held too', () => { + expect(gestureFor(keys({ shiftKey: true }))).toBe('range') + expect(gestureFor(keys({ shiftKey: true, ctrlKey: true }))).toBe('range') + }) +}) + +describe('inPlayLabel', () => { + it('counts what is in play against the whole spool', () => { + expect(inPlayLabel({ ...spool(['a', 'b', 'c'], 'a'), inPlay: 2, hasSelection: true })).toBe( + '2 of 3 in play' + ) + }) +}) diff --git a/src/renderer/helpers/ClipListHelper.ts b/src/renderer/helpers/ClipListHelper.ts index 7644730..4191f14 100644 --- a/src/renderer/helpers/ClipListHelper.ts +++ b/src/renderer/helpers/ClipListHelper.ts @@ -1,4 +1,4 @@ -import type { ClipView, SpoolView } from '../../shared/ipc' +import type { ClipView, SelectGesture, SpoolView } from '../../shared/ipc' /** Pure helpers for the compact window's clip list. No React, no I/O (PLAN.md 6). */ @@ -64,3 +64,25 @@ export function sourceLabel(clip: ClipView): string | null { if (clip.sourceApp === null) return null return clip.sourceApp.replace(/\.exe$/i, '') } + +/** + * Which selection gesture a click carries, from the modifiers held with it (PLAN.md 3). + * + * Shift wins over Ctrl, so Ctrl+Shift+click is a run — the desktop's own lists do the same, and + * inventing a distinct meaning for the pair would be one more thing to learn. Cmd counts as Ctrl + * for the day this runs on a Mac. + */ +export function gestureFor(modifiers: { + readonly shiftKey: boolean + readonly ctrlKey: boolean + readonly metaKey: boolean +}): SelectGesture { + if (modifiers.shiftKey) return 'range' + if (modifiers.ctrlKey || modifiers.metaKey) return 'toggle' + return 'only' +} + +/** The line under the list that admits a selection is narrowing things, and how much. */ +export function inPlayLabel(spool: SpoolView): string { + return `${spool.inPlay} of ${spool.count} in play` +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 341a9ae..06935c7 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -111,6 +111,13 @@ export type SeparatorKind = 'newline' | 'blank_line' | 'tab' | 'comma' | 'space' /** Which size the window is in (PLAN.md 8). */ export type WindowStateName = 'compact' | 'expanded' +/** + * How a click on a clip is meant to change which clips are in play, read from the modifiers held + * with it (PLAN.md 3). The three are the ones every list on the desktop already teaches: a plain + * click chooses one thing, Ctrl adds or removes one, Shift takes a run. + */ +export type SelectGesture = 'only' | 'toggle' | 'range' + /** Enough of a spool to list it. Choosing which one captures is M8. */ /** A standing per-application answer, listed so it can be revoked (PLAN.md 11, M9). */ export interface SourceRuleView { @@ -231,7 +238,7 @@ export const CHANNELS = { toggleMode: 'spool:toggle-mode', setHotkey: 'spool:set-hotkey', setAutoPaste: 'spool:set-auto-paste', - toggleClipSelected: 'spool:toggle-clip-selected', + selectClip: 'spool:select-clip', selectAllClips: 'spool:select-all-clips', resetHotkey: 'spool:reset-hotkey', resumeCapture: 'spool:resume-capture',