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
12 changes: 10 additions & 2 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 37 additions & 1 deletion src/main/core/selection.test.ts
Original file line number Diff line number Diff line change
@@ -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 => ({
Expand Down Expand Up @@ -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']))
})
})
28 changes: 28 additions & 0 deletions src/main/core/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,31 @@ export function toggle(selection: ReadonlySet<string>, clipId: string): Set<stri
else next.add(clipId)
return next
}

/**
* Choose one clip and nothing else. Choosing the clip that is already the whole selection clears
* it — which means all — so a plain click is also the way out, with no separate command to find.
*/
export function only(selection: ReadonlySet<string>, clipId: string): Set<string> {
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<string>,
anchorId: string | null,
clipId: string
): Set<string> {
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))
}
8 changes: 4 additions & 4 deletions src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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)
Expand Down
95 changes: 84 additions & 11 deletions src/main/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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()

Expand All @@ -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()

Expand All @@ -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()
Expand All @@ -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()

Expand All @@ -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)

Expand All @@ -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)', () => {
Expand Down
Loading
Loading