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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Notable changes to **pixelpets**. All art and sound are original/procedural (no
- **Report a problem.** From the tray or Settings > Tools. It shows the exact text first (versions, OS, which features are switched on by name only, and the recent redacted log), then opens GitHub's bug form in your browser, prefilled, when you click. The app itself sends nothing.

### Quick Tools
- **The pet in the launcher pays attention.** It sat in the corner of the box as a still picture. Now it breathes, leans toward what you type when there is something to run, tilts its head when there is not, taps along with your keys, hops when a search finally finds something or you pick a row, and dozes off with a little "z" if you leave the box empty for eight seconds. It holds still under your system's reduced-motion setting and the app's own.
- **Snippets: save text once, get it back by name.** Copy your address, your email sign-off or a meeting link, type `save sig`, and from then on `;sig` copies it again. `;` lists what you have saved and `forget sig` deletes one. Saving reads the clipboard at that moment, so the launcher never handles the text, and the pet never says it back. Snippets are kept in `settings.json` as plain text; a private key or a token in a known format is refused. In all eight languages, and in the `?` list.
- **Type `?` to see what you can type.** Every tool in the launcher was a typed command you had to read the docs to learn. `?` (or `help`, or "What can I type?" at the bottom of the list) now shows an example of each one, and pressing Enter on an example puts its start in the box for you to finish, so `todo ` is waiting for the rest of the sentence. In all eight languages.
- **Fix the text you just copied.** `upper`, `lower`, `title`, `plain` (drops fonts, colours and links before you paste), `one line` (mends the broken lines of a paragraph copied from a PDF or an email) and `count` (words, characters, lines). Typing `text` lists all six. They work whether or not clipboard history is on, because nothing is kept: the clipboard is read only at the moment you run one, the launcher never shows what is on it, and the pet does not repeat it.
Expand Down
5 changes: 5 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ right-click the pet, and a small launcher opens. It is keyboard first: type,
use the arrows, press Enter. With nothing typed it lists your pinned shortcuts,
today's to-dos, running timers and a few one-press actions.

The pet in the corner of the box wears your pet's coat and reacts as you
type: it leans in when there is something to run, tilts its head when there
is not, and dozes if you leave the box empty. With reduced motion on, in the
app or in your system settings, it keeps the pose and does not move.

- **Not sure what to type?** `?` or `help` lists an example of each tool below.
Press Enter on one and its start lands in the box (`todo `, `10m `) for
you to finish. "What can I type?" at the bottom of the list does the same.
Expand Down
33 changes: 33 additions & 0 deletions src/launcher-pet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// What the pet in the launcher header is doing. Pure rules, no DOM: the launcher
// page asks for a mood, puts it on <body data-pet="...">, and CSS poses the
// picture. Nothing here reads a row's wording, only whether it can be run.
//
// Loaded as a classic <script> by launcher.html and required as a CommonJS
// module by the tests.

const PET_DOZE_MS = 8000; // an empty box left alone this long
const PET_MOODS = ['idle', 'listen', 'curious', 'doze'];
const PET_REACTIONS = ['tap', 'hop'];

// idle: nothing typed.
// doze: nothing typed for a while.
// listen: something typed, and there is a row to run.
// curious: something typed, and nothing to run yet (a hint, or no match).
function petMood(state) {
const s = state || {};
if (!String(s.query == null ? '' : s.query).trim()) return (s.idleMs || 0) >= PET_DOZE_MS ? 'doze' : 'idle';
return (s.items || []).some((item) => item && item.enabled) ? 'listen' : 'curious';
}

// A one-off movement on top of the mood, or null. A keystroke is a small tap,
// like kneading. Going from nothing to run to something to run is a hop, and so
// is running a row.
function petReaction(event, before, after) {
if (event === 'run') return 'hop';
if (event !== 'type' || after === 'idle' || after === 'doze') return null;
return before === 'curious' && after === 'listen' ? 'hop' : 'tap';
}

const launcherPet = { petMood, petReaction, PET_DOZE_MS, PET_MOODS, PET_REACTIONS };
if (typeof module !== 'undefined' && module.exports) module.exports = launcherPet;
else globalThis.LauncherPet = launcherPet;
44 changes: 39 additions & 5 deletions src/launcher-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
(() => {
const api = window.launcher;
const Icons = window.LauncherIcons;
const Pet = window.LauncherPet;
const input = document.getElementById('q');
const list = document.getElementById('list');
const tip = document.getElementById('tip');
Expand All @@ -33,6 +34,32 @@
let seq = 0; // drops out-of-order replies when typing fast
let busy = false; // one run at a time
let refreshTimer = null;
let dozeTimer = null;
let pendingPetEvent = null; // what caused the next render: 'type' or 'run'
let idleSince = Date.now(); // when the box last had a keystroke in it

// The pet in the header reacts to what is in the box. The mood is decided from
// flags (is there a row to run?), set as an attribute, and posed by CSS.
function petSet(event) {
const before = document.body.dataset.pet || 'idle';
const after = Pet.petMood({ query: input.value, items, idleMs: Date.now() - idleSince });
document.body.dataset.pet = after;
const once = Pet.petReaction(event, before, after);
if (!once) return;
petCanvas.classList.remove(...Pet.PET_REACTIONS);
void petCanvas.offsetWidth; // restart the animation when the same one repeats
petCanvas.classList.add(once);
}
// A finished tap or hop takes its class with it. Breathing never ends.
petCanvas.addEventListener('animationend', (e) => {
if (e.animationName !== 'pet-breathe') petCanvas.classList.remove(...Pet.PET_REACTIONS);
});
// An empty box left alone dozes off. Any keystroke, or closing, wakes it.
function petRest() {
clearTimeout(dozeTimer);
idleSince = Date.now();
dozeTimer = setTimeout(() => petSet('doze'), Pet.PET_DOZE_MS + 50); // a timer may fire a hair early
}

const el = (tag, cls, text) => {
const n = document.createElement(tag);
Expand Down Expand Up @@ -149,6 +176,8 @@
}
list.replaceChildren(...nodes);
paintSelection();
petSet(pendingPetEvent);
pendingPetEvent = null;
requestAnimationFrame(() => api.resize(document.body.scrollHeight));
}

Expand All @@ -164,9 +193,9 @@
busy = true;
try {
const r = await api.run(input.value, i);
if (r && r.list) render(r.list);
if (r && r.list) { pendingPetEvent = 'run'; petRest(); render(r.list); }
// A help row answers with a starter ("todo ") for you to finish.
if (r && typeof r.fill === 'string') { input.value = r.fill; sel = 0; input.focus(); await refresh(); }
if (r && typeof r.fill === 'string') { input.value = r.fill; sel = 0; input.focus(); pendingPetEvent = 'run'; petRest(); await refresh(); }
} finally { busy = false; }
}

Expand Down Expand Up @@ -202,13 +231,13 @@
if (str('tipSelected')) tipSelected = str('tipSelected');
}

input.addEventListener('input', () => { sel = 0; refresh(); });
input.addEventListener('input', () => { sel = 0; pendingPetEvent = 'type'; petRest(); refresh(); });
input.addEventListener('keydown', (e) => {
if (e.key === 'Alt') { document.body.classList.add('alt'); return; }
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey) || (e.ctrlKey && e.key === 'n')) { e.preventDefault(); move(1); }
else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey) || (e.ctrlKey && e.key === 'p')) { e.preventDefault(); move(-1); }
else if (e.key === 'Enter') { e.preventDefault(); run(sel); }
else if (e.key === 'Escape') { e.preventDefault(); if (input.value) { input.value = ''; sel = 0; refresh(); } else api.hide(); }
else if (e.key === 'Escape') { e.preventDefault(); if (input.value) { input.value = ''; sel = 0; petRest(); refresh(); } else api.hide(); }
// e.code, not e.key: on a Mac, Option+1 types a symbol, so e.key is never "1".
else if (e.altKey && /^Digit[1-8]$/.test(e.code)) { e.preventDefault(); run(Number(e.code.slice(5)) - 1); }
});
Expand All @@ -219,7 +248,8 @@
clearInterval(refreshTimer);
refreshTimer = setInterval(() => { if (!input.value && items.some((x) => x.kind === 'timer')) refresh(); }, REFRESH_MS);
}
window.addEventListener('blur', () => { clearInterval(refreshTimer); document.body.classList.remove('alt'); });
window.addEventListener('blur', () => { clearInterval(refreshTimer); clearTimeout(dozeTimer); document.body.classList.remove('alt'); });
window.addEventListener('focus', petRest);

api.onReset((state) => {
const s = state || {};
Expand All @@ -233,6 +263,10 @@
input.value = '';
sel = 0;
input.focus();
petCanvas.classList.remove(...Pet.PET_REACTIONS);
pendingPetEvent = null;
document.body.dataset.pet = 'idle';
petRest();
refresh();
startRefresh();
});
Expand Down
43 changes: 40 additions & 3 deletions src/launcher.html
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,37 @@

/* header: the pet, the input, the key that closes it */
.bar { display: flex; align-items: center; gap: 12px; padding: 12px 14px 12px 12px; border-bottom: 1px solid var(--sep); }
#pet { width: 34px; height: 40px; flex: none; image-rendering: pixelated; }
/* The pet reacts to what you type (src/launcher-pet.js picks the mood). The
sprite is never redrawn: breathing is `scale`, a mood is a `rotate` pose and
a one-off tap or hop is `translate`, so the three never fight and all of
it stays on the compositor. */
.petbox { position: relative; flex: none; width: 34px; height: 40px; }
#pet { display: block; width: 34px; height: 40px; image-rendering: pixelated; transform-origin: 50% 100%; --breathe: 3.6s;
transition: rotate .22s cubic-bezier(.2, .9, .3, 1.3); animation: pet-breathe var(--breathe) ease-in-out infinite; }
body[data-pet="listen"] #pet { rotate: 5deg; }
body[data-pet="curious"] #pet { rotate: -9deg; }
body[data-pet="doze"] #pet { --breathe: 6s; }
#pet.tap { animation: pet-breathe var(--breathe) ease-in-out infinite, pet-tap .14s ease-out; }
#pet.hop { animation: pet-breathe var(--breathe) ease-in-out infinite, pet-hop .34s cubic-bezier(.3, .7, .4, 1); }
.zz { position: absolute; left: 25px; top: -3px; font: 700 9px/1 "Cascadia Mono", "SF Mono", ui-monospace, Consolas, monospace; color: var(--faint); opacity: 0; pointer-events: none; }
body[data-pet="doze"] .zz { animation: pet-zz 2.6s ease-in-out infinite; }
@keyframes pet-breathe {
0%, 100% { scale: 1 1; }
50% { scale: 1.02 .965; }
}
@keyframes pet-tap {
0%, 100% { translate: 0 0; }
50% { translate: 0 1.5px; }
}
@keyframes pet-hop {
0%, 100% { translate: 0 0; }
45% { translate: 0 -6px; }
}
@keyframes pet-zz {
0% { opacity: 0; translate: 0 2px; }
40% { opacity: .9; }
100% { opacity: 0; translate: 3px -6px; }
}
#q { flex: 1; min-width: 0; background: transparent; border: 0; outline: 0; color: var(--fg); font: 400 19px/1.2 inherit; caret-color: var(--accent); }
#q::placeholder { color: var(--faint); }
kbd { font: 600 10.5px/1 "Segoe UI", system-ui, sans-serif; color: var(--muted); background: var(--kbd); border: 1px solid var(--hair); border-bottom-width: 2px; border-radius: 5px; padding: 3px 6px; }
Expand Down Expand Up @@ -118,14 +148,20 @@
.foot span { display: inline-flex; align-items: center; gap: 5px; }
.foot .tip { margin-left: auto; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }

@media (prefers-reduced-motion: reduce) { body.enter .shell { animation: none; } .row, .switch, .switch::after { transition: none; } }
@media (prefers-reduced-motion: reduce) {
body.enter .shell { animation: none; }
.row, .switch, .switch::after { transition: none; }
#pet, #pet.tap, #pet.hop, body[data-pet="doze"] .zz { animation: none; transition: none; }
}
body.still .shell { animation: none !important; }
/* Held still, the pet keeps its pose and loses every movement. */
body.still #pet, body.still #pet.tap, body.still #pet.hop, body.still .zz { animation: none !important; transition: none; }
</style>
</head>
<body>
<div class="shell">
<div class="bar">
<canvas id="pet" width="34" height="40" aria-hidden="true"></canvas>
<span class="petbox" aria-hidden="true"><canvas id="pet" width="34" height="40"></canvas><span class="zz">z</span></span>
<input id="q" type="text" autocomplete="off" spellcheck="false"
role="combobox" aria-expanded="true" aria-controls="list" aria-autocomplete="list"
placeholder="Search apps, notes, timers, anything…" />
Expand All @@ -145,6 +181,7 @@
<script src="pets.js"></script>
<script src="cat-preview.js"></script>
<script src="launcher-icons.js"></script>
<script src="launcher-pet.js"></script>
<script src="launcher-renderer.js"></script>
</body>
</html>
88 changes: 88 additions & 0 deletions tests/launcher-pet.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// The pet in the launcher header reacts to what you type. Which mood it is in is
// a pure decision; the page only sets an attribute and lets CSS move the picture.
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');

const pet = require('../src/launcher-pet');

const SRC = path.join(__dirname, '..', 'src');
const read = (f) => fs.readFileSync(path.join(SRC, f), 'utf8');
const row = (enabled) => ({ enabled });

test('an empty box is idle, and dozes once it has been left alone', () => {
assert.strictEqual(pet.petMood({ query: '', items: [row(true)], idleMs: 0 }), 'idle');
assert.strictEqual(pet.petMood({ query: ' ', items: [row(true)], idleMs: pet.PET_DOZE_MS - 1 }), 'idle');
assert.strictEqual(pet.petMood({ query: '', items: [row(true)], idleMs: pet.PET_DOZE_MS }), 'doze');
assert.strictEqual(pet.petMood({ query: 'x', items: [row(true)], idleMs: pet.PET_DOZE_MS * 9 }), 'listen', 'it never dozes mid-sentence');
});

test('it listens when there is something to run and is curious when there is not', () => {
assert.strictEqual(pet.petMood({ query: 'gmail', items: [row(true), row(false)] }), 'listen');
assert.strictEqual(pet.petMood({ query: 'todo ', items: [row(false)] }), 'curious', 'a hint row is not something to run');
assert.strictEqual(pet.petMood({ query: 'zzqq', items: [] }), 'curious');
assert.strictEqual(pet.petMood({ query: 'x' }), 'curious', 'no list at all');
assert.strictEqual(pet.petMood({}), 'idle');
});

test('a keystroke is a tap, finding something after nothing is a hop, and so is running a row', () => {
assert.strictEqual(pet.petReaction('type', 'listen', 'listen'), 'tap');
assert.strictEqual(pet.petReaction('type', 'idle', 'listen'), 'tap');
assert.strictEqual(pet.petReaction('type', 'curious', 'listen'), 'hop');
assert.strictEqual(pet.petReaction('type', 'listen', 'curious'), 'tap');
assert.strictEqual(pet.petReaction('run', 'listen', 'listen'), 'hop');
assert.strictEqual(pet.petReaction('type', 'listen', 'idle'), null, 'clearing the box is not a keystroke to react to');
assert.strictEqual(pet.petReaction('doze', 'idle', 'doze'), null);
assert.strictEqual(pet.petReaction('anything-else', 'idle', 'idle'), null);
});

test('the page loads the mood rules before the script that uses them', () => {
const scripts = [...read('launcher.html').matchAll(/<script src="([^"]+)"><\/script>/g)].map((m) => m[1]);
assert.ok(scripts.includes('launcher-pet.js'));
assert.ok(scripts.indexOf('launcher-pet.js') < scripts.indexOf('launcher-renderer.js'));
});

test('every mood the rules can return has a pose, and motion can be switched off', () => {
const html = read('launcher.html');
for (const mood of pet.PET_MOODS) {
if (mood === 'idle') continue; // the resting pose needs no rule
assert.match(html, new RegExp(`data-pet="${mood}"`), `no CSS for the "${mood}" mood`);
}
for (const once of pet.PET_REACTIONS) assert.match(html, new RegExp(`#pet\\.${once}\\b`), `no CSS for the "${once}" reaction`);
// Reduced motion (the OS setting) and the app's own setting both stop it.
const reduced = /@media \(prefers-reduced-motion: reduce\) \{([^]*?)\n {4}\}/.exec(html);
assert.ok(reduced && /#pet[^{]*\{[^}]*animation: none/.test(reduced[1]), 'the OS reduced-motion setting must still the pet');
assert.match(html, /body\.still #pet[^{]*\{[^}]*animation: none/);
// Only properties the compositor can animate without laying the page out again.
const frames = [...html.matchAll(/@keyframes pet-[\w-]+ \{([^]*?)\n {4}\}/g)].map((m) => m[1]).join('\n');
assert.ok(frames.length > 0);
const props = new Set([...frames.matchAll(/([a-z-]+)\s*:/g)].map((m) => m[1]));
for (const p of props) assert.ok(['scale', 'translate', 'rotate', 'opacity'].includes(p), `keyframes animate "${p}"`);
});

test('slowing the breath for a doze cannot stretch a tap or a hop', () => {
const html = read('launcher.html');
// One duration on a rule that outranks #pet.hop would apply to the whole list.
assert.doesNotMatch(html, /data-pet="doze"\] #pet \{[^}]*animation-duration/);
assert.match(html, /data-pet="doze"\] #pet \{ --breathe: \d/);
for (const once of pet.PET_REACTIONS) assert.match(html, new RegExp(`#pet\\.${once} \\{ animation: pet-breathe var\\(--breathe\\)[^}]*pet-${once} `));
});

test('anything that empties or uses the box counts as activity, so it does not doze at once', () => {
const js = read('launcher-renderer.js');
const escape = /e\.key === 'Escape'[^\n]*/.exec(js)[0];
assert.match(escape, /petRest\(\)/, 'Escape clears the box without an input event');
assert.match(js, /pendingPetEvent = 'run'; petRest\(\)/);
assert.match(js, /addEventListener\('focus', petRest\)/);
// A reaction ends with its animation, not with whichever render comes next.
assert.match(js, /addEventListener\('animationend'/);
// Reopening starts from rest.
assert.match(js, /pendingPetEvent = null;\s+document\.body\.dataset\.pet = 'idle';/);
});

test('the launcher decides the mood from flags, never from what a row says', () => {
const js = read('launcher-renderer.js');
assert.match(js, /petMood\(/);
assert.doesNotMatch(js, /petMood\([^)]*title/);
});
Loading