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/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. 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',