@kecan0406/ttheme 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ttheme.js CHANGED
@@ -4098,6 +4098,9 @@ var require_src = __commonJS((exports, module) => {
4098
4098
  module.exports = { cursor, scroll, erase, beep };
4099
4099
  });
4100
4100
 
4101
+ // src/bin.ts
4102
+ import { isMainThread as isMainThread2 } from "node:worker_threads";
4103
+
4101
4104
  // node_modules/commander/lib/error.js
4102
4105
  class CommanderError extends Error {
4103
4106
  constructor(exitCode, code, message) {
@@ -6144,7 +6147,7 @@ var program = new Command;
6144
6147
  // package.json
6145
6148
  var package_default = {
6146
6149
  name: "@kecan0406/ttheme",
6147
- version: "0.1.2",
6150
+ version: "0.1.4",
6148
6151
  type: "module",
6149
6152
  bin: {
6150
6153
  ttheme: "bin/ttheme.js"
@@ -6804,6 +6807,42 @@ var CONFIG_SETTINGS = {
6804
6807
  TTHEME_SORT: {
6805
6808
  doc: "# series and palettes in ttheme and preview: abc sorts them by name, series keeps the order they were added (default abc)",
6806
6809
  default: "abc"
6810
+ },
6811
+ TTHEME_FIND_RATING: {
6812
+ doc: "# the ratings find lists, any of safe, questionable and explicit, each booru read in its own rating vocabulary (default safe)",
6813
+ default: "safe"
6814
+ },
6815
+ TTHEME_FIND_BLOCK: {
6816
+ doc: '# the posts find drops by tag: nudity, underwear, both, or none to keep them all (default "nudity underwear")',
6817
+ default: "nudity underwear"
6818
+ },
6819
+ TTHEME_FIND_POSTS: {
6820
+ doc: "# what find lists first: all is every post of the character, cutouts are the transparent ones (default all)",
6821
+ default: "all"
6822
+ },
6823
+ TTHEME_FIND_CUTOUTS: {
6824
+ doc: '# the tags find calls a transparent cutout, per site as key=tag,tag pairs — e.g. "safebooru=transparent_background yande=transparent_png,vector" (default the built-in tags)',
6825
+ default: ""
6826
+ },
6827
+ TTHEME_FIND_ORDER: {
6828
+ doc: "# the order find lists posts in: newest or score (default newest)",
6829
+ default: "newest"
6830
+ },
6831
+ TTHEME_FIND_SETS: {
6832
+ doc: "# runs of the same picture at the same size from one uploader: fold shows them as one tile, show lists each (default fold)",
6833
+ default: "fold"
6834
+ },
6835
+ TTHEME_FIND_REMOVE_BG: {
6836
+ doc: "# an opaque picture tried on in find: on cuts the character out with macOS Vision, off leaves it as it is (default on)",
6837
+ default: "on"
6838
+ },
6839
+ TTHEME_FIND_UNBLOCK: {
6840
+ doc: "# when a network blocks a booru by name, 1 sends find through a local proxy that splits the TLS handshake (default 0)",
6841
+ default: "0"
6842
+ },
6843
+ TTHEME_FIND_HOSTS: {
6844
+ doc: '# send a find site somewhere else, as key=https://host pairs — e.g. "konachan=https://konachan.com danbooru=https://danbooru.donmai.us" (default none)',
6845
+ default: ""
6807
6846
  }
6808
6847
  };
6809
6848
  function settingLine(name, value) {
@@ -6831,6 +6870,17 @@ ${settingLine(name, s.default)}`);
6831
6870
  `)}
6832
6871
  `;
6833
6872
  }
6873
+ function withSetting(content, name, value) {
6874
+ const line = settingLine(name, value);
6875
+ const pattern = settingPattern(name);
6876
+ if (pattern.test(content)) {
6877
+ return content.replace(pattern, line);
6878
+ }
6879
+ return `${content.replace(/\n*$/, `
6880
+ `)}
6881
+ ${line}
6882
+ `;
6883
+ }
6834
6884
  function configFile(content) {
6835
6885
  if (content === "") {
6836
6886
  return configTemplate();
@@ -6848,7 +6898,7 @@ import = ["${themePath}"]` : "";
6848
6898
  // src/emit/shell.ts
6849
6899
  function palettesZsh(palettes) {
6850
6900
  for (const p of palettes) {
6851
- for (const field of [p.name, p.group, p.native ?? "", p.ansiSource, p.booru ?? ""]) {
6901
+ for (const field of [p.name, p.group, p.native ?? "", p.ansiSource]) {
6852
6902
  if (/["$`\\]/.test(field)) {
6853
6903
  throw new Error(`${p.name}: "${field}" contains a character that breaks zsh quoting`);
6854
6904
  }
@@ -6890,11 +6940,6 @@ function palettesZsh(palettes) {
6890
6940
  "typeset -gA TTHEME_SRC=(",
6891
6941
  ...palettes.map((p) => entry(p, p.ansiSource)),
6892
6942
  ")",
6893
- "",
6894
- "# the safebooru tag preview searches when a palette has no background yet",
6895
- "typeset -gA TTHEME_BOORU=(",
6896
- ...palettes.filter((p) => p.booru).map((p) => entry(p, p.booru ?? "")),
6897
- ")",
6898
6943
  ""
6899
6944
  ].join(`
6900
6945
  `);
@@ -6999,12 +7044,13 @@ ${themes.length} themes (${rotation(themes).length} in the new-tab rotation) ->
6999
7044
  }
7000
7045
 
7001
7046
  // src/find.ts
7002
- import { existsSync as existsSync3, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync4, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
7047
+ import { spawn as spawn2 } from "node:child_process";
7048
+ import { existsSync as existsSync3, mkdirSync as mkdirSync5, mkdtempSync, readFileSync as readFileSync6, rmSync as rmSync5, statSync as statSync3, writeFileSync as writeFileSync5 } from "node:fs";
7003
7049
  import { tmpdir } from "node:os";
7004
- import { dirname as dirname3, join as join7 } from "node:path";
7050
+ import { dirname as dirname4, join as join7 } from "node:path";
7005
7051
 
7006
7052
  // src/backdrop.ts
7007
- import { mkdirSync, readdirSync as readdirSync2, rmSync as rmSync2, statSync, writeFileSync } from "node:fs";
7053
+ import { mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync2, renameSync, rmSync as rmSync2, statSync, utimesSync, writeFileSync } from "node:fs";
7008
7054
  import { join as join3 } from "node:path";
7009
7055
 
7010
7056
  // src/png.ts
@@ -7023,12 +7069,21 @@ function decodePng(bytes) {
7023
7069
  const png = import_pngjs.PNG.sync.read(Buffer.from(bytes));
7024
7070
  return { width: png.width, height: png.height, data: new Uint8Array(png.data) };
7025
7071
  }
7026
- function decodeImage(bytes) {
7072
+ function decodeImage(bytes, limit) {
7027
7073
  if (isPng(bytes)) {
7074
+ const head = pngHead(bytes);
7075
+ if (head && head.width * head.height > limit) {
7076
+ throw new Error(`${head.width}×${head.height} is over ${limit / 1e6} megapixels`);
7077
+ }
7028
7078
  return decodePng(bytes);
7029
7079
  }
7030
7080
  if (bytes[0] === 255 && bytes[1] === 216) {
7031
- const jpeg = import_jpeg_js.decode(bytes, { useTArray: true, formatAsRGBA: true, maxMemoryUsageInMB: 1024 });
7081
+ const jpeg = import_jpeg_js.decode(bytes, {
7082
+ useTArray: true,
7083
+ formatAsRGBA: true,
7084
+ maxResolutionInMP: limit / 1e6,
7085
+ maxMemoryUsageInMB: 1024
7086
+ });
7032
7087
  return { width: jpeg.width, height: jpeg.height, data: jpeg.data };
7033
7088
  }
7034
7089
  throw new Error("not a PNG or JPEG image");
@@ -7060,9 +7115,8 @@ function pngHead(bytes) {
7060
7115
  if (bytes[25] === 4 || bytes[25] === 6) {
7061
7116
  return { width, height, alpha: true };
7062
7117
  }
7063
- const trns = find(bytes, "tRNS");
7064
7118
  const idat = find(bytes, "IDAT");
7065
- return { width, height, alpha: trns !== -1 && (idat === -1 || trns < idat) };
7119
+ return { width, height, alpha: find(idat === -1 ? bytes : bytes.subarray(0, idat), "tRNS") !== -1 };
7066
7120
  }
7067
7121
  function alphaBox(image, threshold = CLEAR) {
7068
7122
  let x0 = image.width;
@@ -7180,7 +7234,11 @@ var FILL = { width: 2560, height: 1550 };
7180
7234
  var FIGURE = 2560;
7181
7235
  var FAINT = 0.1;
7182
7236
  var HEADROOM = 0.15;
7237
+ var KEEP = 0.55;
7238
+ var PLACE = 0.65;
7239
+ var STANDS = 3;
7183
7240
  var PEAK = luminance(mix("#19161e", "#9b86c8", 0.2));
7241
+ var SHELF = "shelf";
7184
7242
  function mix(a, b, t) {
7185
7243
  const from = rgb(a);
7186
7244
  const to = rgb(b);
@@ -7270,11 +7328,14 @@ function figureBox(image) {
7270
7328
  function headAnchor(box) {
7271
7329
  return Math.min(0.5, (HEADROOM + 0.5) * (box.w * FILL.height / FILL.width / box.h));
7272
7330
  }
7331
+ function band(box, h, anchor) {
7332
+ const top = Math.min(Math.max(0, anchor * box.h - h / 2), box.h - h);
7333
+ return { x: box.x, y: box.y + top, w: box.w, h };
7334
+ }
7273
7335
  function fillBox(box, width, height, anchor) {
7274
7336
  const h = box.w * height / width;
7275
7337
  if (h <= box.h) {
7276
- const top = Math.min(Math.max(0, anchor * box.h - h / 2), box.h - h);
7277
- return { x: box.x, y: box.y + top, w: box.w, h };
7338
+ return band(box, h, anchor);
7278
7339
  }
7279
7340
  const w = box.h * width / height;
7280
7341
  return { x: box.x + (box.w - w) / 2, y: box.y, w, h: box.h };
@@ -7291,20 +7352,66 @@ function figure(image, box) {
7291
7352
  const k = Math.min(1, FIGURE / Math.max(box.w, box.h));
7292
7353
  return resample(image, box, Math.round(box.w * k), Math.round(box.h * k));
7293
7354
  }
7294
- function fill(image, box, anchor) {
7295
- return resample(image, fillBox(box, FILL.width, FILL.height, anchor), FILL.width, FILL.height);
7355
+ function fillFrame(box, width, height, anchor, clear) {
7356
+ const keep = KEEP * box.h;
7357
+ const span = box.w * height / width;
7358
+ if (clear < STANDS || span > keep) {
7359
+ return { crop: fillBox(box, width, height, anchor), at: { x: 0, y: 0, w: width, h: height }, inset: false };
7360
+ }
7361
+ const short = 1 - span / keep;
7362
+ const h = height * (1 - HEADROOM * short);
7363
+ const w = box.w * h / keep;
7364
+ return {
7365
+ crop: band(box, keep, anchor),
7366
+ at: { x: (width - w) * PLACE, y: height - h, w, h },
7367
+ inset: true
7368
+ };
7369
+ }
7370
+ function paint(image, frame, width, height) {
7371
+ const w = Math.max(1, Math.round(frame.at.w));
7372
+ const h = Math.max(1, Math.round(frame.at.h));
7373
+ const cut = resample(image, frame.crop, w, h);
7374
+ const out = new Uint8Array(width * height * 4);
7375
+ const ox = Math.round(frame.at.x);
7376
+ const oy = Math.round(frame.at.y);
7377
+ for (let y = 0;y < h; y++) {
7378
+ const ty = oy + y;
7379
+ const from = Math.max(0, -ox);
7380
+ const to = Math.min(w, width - ox);
7381
+ if (ty < 0 || ty >= height || to <= from) {
7382
+ continue;
7383
+ }
7384
+ out.set(cut.data.subarray((y * w + from) * 4, (y * w + to) * 4), (ty * width + ox + from) * 4);
7385
+ }
7386
+ return { width, height, data: out };
7387
+ }
7388
+ function fill(image, box, anchor, clear) {
7389
+ const frame = fillFrame(box, FILL.width, FILL.height, anchor, clear);
7390
+ return frame.inset ? paint(image, frame, FILL.width, FILL.height) : resample(image, frame.crop, FILL.width, FILL.height);
7296
7391
  }
7297
- function tryOn(image, colors, tone, width, height) {
7392
+ function tryOn(image, colors, tone, width, height, clear) {
7298
7393
  const box = figureBox(image);
7299
- const view = coverBox(fillBox(box, FILL.width, FILL.height, headAnchor(box)), width, height);
7300
- return composite(tint(resample(image, view, width, height), colors.background, tone.color), colors.background, tone.opacity);
7394
+ const frame = fillFrame(box, FILL.width, FILL.height, headAnchor(box), clear);
7395
+ let shown;
7396
+ if (frame.inset) {
7397
+ const view = coverBox({ x: 0, y: 0, w: FILL.width, h: FILL.height }, width, height);
7398
+ const k = width / view.w;
7399
+ shown = paint(image, {
7400
+ ...frame,
7401
+ at: { x: (frame.at.x - view.x) * k, y: (frame.at.y - view.y) * k, w: frame.at.w * k, h: frame.at.h * k }
7402
+ }, width, height);
7403
+ } else {
7404
+ shown = resample(image, coverBox(frame.crop, width, height), width, height);
7405
+ }
7406
+ return composite(tint(shown, colors.background, tone.color), colors.background, tone.opacity);
7301
7407
  }
7302
7408
  function backgroundsDir(configHome) {
7303
7409
  return join3(configHome, "ttheme", "backgrounds");
7304
7410
  }
7305
- function backdropConf(name, fillPath, opacity, from) {
7411
+ function backdropConf(name, fillPath, opacity, from, key) {
7306
7412
  return [
7307
7413
  ...from ? [`# from ${from}`] : [],
7414
+ ...key ? [`# image ${key}`] : [],
7308
7415
  `background-image = ${fillPath}`,
7309
7416
  "background-image-fit = cover",
7310
7417
  "background-image-position = center",
@@ -7322,26 +7429,139 @@ function clearBackdrop(dir, name) {
7322
7429
  }
7323
7430
  }
7324
7431
  }
7432
+ function imageKey(origin) {
7433
+ return `${origin.site}_${origin.id}`;
7434
+ }
7435
+ function shelfDir(dir, name) {
7436
+ return join3(dir, SHELF, name);
7437
+ }
7438
+ function belongs(file, name) {
7439
+ const rest = file.slice(name.length);
7440
+ return file.startsWith(name) && (rest === ".png" || rest === ".conf" || /^@fill-\d+\.png$/.test(rest));
7441
+ }
7442
+ function activeKey(dir, name) {
7443
+ try {
7444
+ return /^# image (\S+)$/m.exec(readFileSync2(join3(dir, `${name}.conf`), "utf8"))?.[1];
7445
+ } catch {
7446
+ return;
7447
+ }
7448
+ }
7449
+ function shelved(dir, name) {
7450
+ try {
7451
+ return readdirSync2(shelfDir(dir, name));
7452
+ } catch {
7453
+ return [];
7454
+ }
7455
+ }
7456
+ function imageKeys(dir, name) {
7457
+ const active = activeKey(dir, name);
7458
+ return [...active ? [active] : [], ...shelved(dir, name)].sort();
7459
+ }
7460
+ function shelve(dir, name) {
7461
+ const key = activeKey(dir, name);
7462
+ if (!key) {
7463
+ return;
7464
+ }
7465
+ const to = join3(shelfDir(dir, name), key);
7466
+ rmSync2(to, { recursive: true, force: true });
7467
+ mkdirSync(to, { recursive: true });
7468
+ for (const file of readdirSync2(dir)) {
7469
+ if (belongs(file, name)) {
7470
+ renameSync(join3(dir, file), join3(to, file.slice(name.length)));
7471
+ }
7472
+ }
7473
+ clearBackdrop(dir, name);
7474
+ }
7475
+ function unshelve(dir, name, key) {
7476
+ const from = join3(shelfDir(dir, name), key);
7477
+ for (const file of readdirSync2(from)) {
7478
+ renameSync(join3(from, file), join3(dir, `${name}${file}`));
7479
+ }
7480
+ rmSync2(from, { recursive: true });
7481
+ }
7482
+ function originalOf(dir, name, key) {
7483
+ try {
7484
+ return readdirSync2(join3(dir, "originals")).find((file) => file.startsWith(`${name}-${key}.`));
7485
+ } catch {
7486
+ return;
7487
+ }
7488
+ }
7489
+ function newest(dir, name, key) {
7490
+ const file = originalOf(dir, name, key);
7491
+ if (file) {
7492
+ const now = new Date;
7493
+ utimesSync(join3(dir, "originals", file), now, now);
7494
+ }
7495
+ }
7496
+ function switchImage(configHome, name, step) {
7497
+ const dir = backgroundsDir(configHome);
7498
+ const keys = imageKeys(dir, name);
7499
+ const now = activeKey(dir, name);
7500
+ if (!now || keys.length < 2) {
7501
+ throw new Error(`${name} has no other image`);
7502
+ }
7503
+ const at2 = (keys.indexOf(now) + step + keys.length) % keys.length;
7504
+ const key = keys[at2];
7505
+ shelve(dir, name);
7506
+ unshelve(dir, name, key);
7507
+ newest(dir, name, key);
7508
+ return { key, at: at2 + 1, of: keys.length };
7509
+ }
7510
+ function dropImage(configHome, name) {
7511
+ const dir = backgroundsDir(configHome);
7512
+ const files = readdirSync2(dir).filter((file) => belongs(file, name));
7513
+ if (files.length === 0) {
7514
+ throw new Error(`${name} has no image`);
7515
+ }
7516
+ const keys = imageKeys(dir, name);
7517
+ const now = activeKey(dir, name);
7518
+ const original = now && originalOf(dir, name, now);
7519
+ if (original) {
7520
+ rmSync2(join3(dir, "originals", original));
7521
+ }
7522
+ for (const file of files) {
7523
+ rmSync2(join3(dir, file));
7524
+ }
7525
+ clearBackdrop(dir, name);
7526
+ const rest = keys.filter((key) => key !== now);
7527
+ const next = rest[(now ? keys.indexOf(now) : 0) % rest.length];
7528
+ if (next) {
7529
+ unshelve(dir, name, next);
7530
+ newest(dir, name, next);
7531
+ }
7532
+ return { key: now, left: rest.length };
7533
+ }
7534
+ function retire(dir, name, key) {
7535
+ if (activeKey(dir, name) !== key) {
7536
+ shelve(dir, name);
7537
+ }
7538
+ if (key) {
7539
+ rmSync2(join3(shelfDir(dir, name), key), { recursive: true, force: true });
7540
+ }
7541
+ clearBackdrop(dir, name);
7542
+ }
7325
7543
  function installBackdrop(configHome, colors, tone, image, original) {
7326
7544
  const dir = backgroundsDir(configHome);
7327
7545
  const name = colors.name;
7546
+ const key = imageKey(original);
7328
7547
  mkdirSync(join3(dir, "originals"), { recursive: true });
7329
- clearBackdrop(dir, name);
7548
+ retire(dir, name, key);
7330
7549
  const box = figureBox(image);
7331
7550
  const anchor = headAnchor(box);
7551
+ const clear = transparency(image);
7332
7552
  const whole = join3(dir, `${name}.png`);
7333
7553
  const fillPath = join3(dir, `${name}@fill-${Math.round(anchor * 100)}.png`);
7334
7554
  const conf = join3(dir, `${name}.conf`);
7335
7555
  const source = join3(dir, "originals", `${name}-${original.site}_${original.id}.${original.ext}`);
7336
7556
  writeFileSync(whole, encodePng(tint(figure(image, box), colors.background, tone.color)));
7337
- writeFileSync(fillPath, encodePng(tint(fill(image, box, anchor), colors.background, tone.color)));
7338
- writeFileSync(conf, backdropConf(name, fillPath, tone.opacity, original.from));
7557
+ writeFileSync(fillPath, encodePng(tint(fill(image, box, anchor, clear), colors.background, tone.color)));
7558
+ writeFileSync(conf, backdropConf(name, fillPath, tone.opacity, original.from, key));
7339
7559
  writeFileSync(source, original.bytes);
7340
7560
  return [whole, fillPath, conf, source];
7341
7561
  }
7342
7562
  function origins(configHome) {
7343
7563
  const dir = join3(backgroundsDir(configHome), "originals");
7344
- const newest = new Map;
7564
+ const newest2 = new Map;
7345
7565
  let files;
7346
7566
  try {
7347
7567
  files = readdirSync2(dir);
@@ -7355,23 +7575,49 @@ function origins(configHome) {
7355
7575
  continue;
7356
7576
  }
7357
7577
  const at2 = statSync(join3(dir, file)).mtimeMs;
7358
- const known = newest.get(name);
7578
+ const known = newest2.get(name);
7359
7579
  if (!known || at2 > known.at) {
7360
- newest.set(name, { site, id: Number(id), at: at2 });
7580
+ newest2.set(name, { site, id: Number(id), at: at2 });
7361
7581
  }
7362
7582
  }
7363
- return new Map([...newest].map(([name, { site, id }]) => [name, { site, id }]));
7583
+ return new Map([...newest2].map(([name, { site, id }]) => [name, { site, id }]));
7364
7584
  }
7365
7585
 
7366
7586
  // src/booru.ts
7587
+ import { readdirSync as readdirSync3, rmSync as rmSync3, statSync as statSync2 } from "node:fs";
7588
+ import { setDefaultAutoSelectFamilyAttemptTimeout } from "node:net";
7367
7589
  import { homedir } from "node:os";
7368
7590
  import { join as join4 } from "node:path";
7591
+ import { setTimeout as sleep } from "node:timers/promises";
7369
7592
  var PAGE = 100;
7370
- var SAFE = new Set(["safe", "general", "s", "g"]);
7593
+ var MAX_PIXELS = 25000000;
7594
+ var CACHE_BYTES = 512 * 1024 * 1024;
7595
+ var RATINGS = ["safe", "questionable", "explicit"];
7596
+ var BLOCKS = ["nudity", "underwear"];
7597
+ var EXPOSED = {
7598
+ nudity: new Set(["nude", "naked", "topless", "bottomless", "nipples", "naked_towel", "undressing"]),
7599
+ underwear: new Set(["underwear", "panties", "pantsu", "bra", "lingerie", "pantyshot"])
7600
+ };
7601
+ var MOEBOORU = { safe: "s", questionable: "q", explicit: "e" };
7371
7602
  var MIRRORS = new Set(["danbooru", "gelbooru", "konachan", "yande.re", "sankaku"]);
7372
7603
  var HEAD = 8191;
7373
7604
  var TIMEOUT = 20000;
7605
+ var CONNECT = 3000;
7606
+ var MAX_WAIT = 60000;
7607
+ var TRIES = 3;
7374
7608
  var AGENT = `ttheme/${package_default.version} (+${package_default.homepage})`;
7609
+ var HELD = ["orig", "tile", "thumb", "cut"];
7610
+ var paused = new Map;
7611
+ var HOSTS = hostMap(process.env.TTHEME_FIND_HOSTS);
7612
+ var CUTOUTS = cutoutMap(process.env.TTHEME_FIND_CUTOUTS);
7613
+ setDefaultAutoSelectFamilyAttemptTimeout(CONNECT);
7614
+ function listing(dir) {
7615
+ try {
7616
+ return readdirSync3(dir);
7617
+ } catch {
7618
+ return [];
7619
+ }
7620
+ }
7375
7621
  function absolute(url) {
7376
7622
  return url.startsWith("//") ? `https:${url}` : url;
7377
7623
  }
@@ -7384,26 +7630,45 @@ function records(text) {
7384
7630
  return [];
7385
7631
  }
7386
7632
  const raw = JSON.parse(body);
7387
- return Array.isArray(raw) ? raw : [];
7633
+ if (Array.isArray(raw)) {
7634
+ return raw;
7635
+ }
7636
+ const posts = raw.posts;
7637
+ return Array.isArray(posts) ? posts : [];
7638
+ }
7639
+ function tagTypes(text) {
7640
+ const body = text.trim();
7641
+ const raw = body ? JSON.parse(body) : {};
7642
+ const tags = raw.tags ?? {};
7643
+ return new Map(Object.entries(tags).map(([name, type]) => [name, String(type)]));
7388
7644
  }
7389
- function post(p, file, ext, owner, md5) {
7390
- const preview = absolute(String(p.preview_url ?? ""));
7391
- if (!file || !preview || !Number(p.id)) {
7645
+ function version(url, width, height) {
7646
+ const file = absolute(String(url ?? ""));
7647
+ return { file, width: Number(width) || 0, height: Number(height) || 0, ext: extension(file) };
7648
+ }
7649
+ function post(p, raw) {
7650
+ const preview = absolute(String(raw.preview ?? ""));
7651
+ if (!raw.file || !preview || !Number(p.id)) {
7392
7652
  return [];
7393
7653
  }
7654
+ const width = Number(p.width ?? p.image_width) || 0;
7655
+ const height = Number(p.height ?? p.image_height) || 0;
7394
7656
  return [
7395
7657
  {
7396
7658
  id: Number(p.id),
7397
- width: Number(p.width) || 0,
7398
- height: Number(p.height) || 0,
7399
- file,
7659
+ width,
7660
+ height,
7661
+ file: raw.file,
7400
7662
  preview,
7401
- ext,
7402
- owner: String(owner ?? ""),
7663
+ ext: raw.ext,
7664
+ owner: String(raw.owner ?? ""),
7665
+ artist: raw.artist,
7666
+ score: Number(p.score) || 0,
7403
7667
  rating: String(p.rating ?? ""),
7404
- md5: String(md5 ?? ""),
7668
+ md5: String(raw.md5 ?? ""),
7405
7669
  source: String(p.source ?? ""),
7406
- tags: String(p.tags ?? "").split(/\s+/).filter(Boolean)
7670
+ tags: raw.tags.split(/\s+/).filter(Boolean),
7671
+ smaller: raw.versions.filter((v) => v.file && v.width > 0 && v.height > 0 && v.width * v.height < width * height)
7407
7672
  }
7408
7673
  ];
7409
7674
  }
@@ -7411,54 +7676,239 @@ function parseGelbooru(text, origin = "https://safebooru.org") {
7411
7676
  return records(text).flatMap((p) => {
7412
7677
  const stored = p.directory && p.image ? `${origin}/images/${p.directory}/${p.image}` : "";
7413
7678
  const file = absolute(String(p.file_url || stored));
7414
- return post(p, file, extension(file), p.owner, p.hash ?? p.md5);
7679
+ return post(p, {
7680
+ preview: p.preview_url,
7681
+ file,
7682
+ ext: extension(file),
7683
+ owner: p.owner,
7684
+ artist: "",
7685
+ md5: p.hash ?? p.md5,
7686
+ tags: String(p.tags ?? ""),
7687
+ versions: [version(p.sample_url, p.sample_width, p.sample_height)]
7688
+ });
7415
7689
  });
7416
7690
  }
7417
7691
  function parseMoebooru(text) {
7692
+ const types = tagTypes(text);
7693
+ return records(text).flatMap((p) => {
7694
+ const file = absolute(String(p.file_url ?? ""));
7695
+ const tags = String(p.tags ?? "");
7696
+ return post(p, {
7697
+ preview: p.preview_url,
7698
+ file,
7699
+ ext: String(p.file_ext || extension(file)).toLowerCase(),
7700
+ owner: p.author,
7701
+ artist: tags.split(/\s+/).find((tag) => types.get(tag) === "artist") ?? "",
7702
+ md5: p.md5,
7703
+ tags,
7704
+ versions: [
7705
+ version(p.jpeg_url, p.jpeg_width, p.jpeg_height),
7706
+ version(p.sample_url, p.sample_width, p.sample_height)
7707
+ ]
7708
+ });
7709
+ });
7710
+ }
7711
+ function parseDanbooru(text) {
7418
7712
  return records(text).flatMap((p) => {
7419
7713
  const file = absolute(String(p.file_url ?? ""));
7420
- return post(p, file, String(p.file_ext || extension(file)).toLowerCase(), p.author, p.md5);
7714
+ const asset = p.media_asset ?? {};
7715
+ return post(p, {
7716
+ preview: p.preview_file_url,
7717
+ file,
7718
+ ext: String(p.file_ext || extension(file)).toLowerCase(),
7719
+ owner: "",
7720
+ artist: String(p.tag_string_artist ?? "").split(/\s+/)[0] ?? "",
7721
+ md5: p.md5,
7722
+ tags: String(p.tag_string ?? ""),
7723
+ versions: (asset.variants ?? []).map((v) => version(v.url, v.width, v.height)).filter((v) => v.ext === "jpg" || v.ext === "png").sort((a, b) => b.width * b.height - a.width * a.height)
7724
+ });
7421
7725
  });
7422
7726
  }
7423
7727
  function parseCount(xml) {
7424
7728
  return Number(/count="(\d+)"/.exec(xml)?.[1] ?? 0);
7425
7729
  }
7426
- function gelbooru(key, name, origin, cutouts, vouched, ansi) {
7427
- const api = (params) => `${origin}/index.php?${new URLSearchParams({ page: "dapi", s: "post", q: "index", ...params })}`;
7730
+ function parseCounts(text) {
7731
+ const raw = text.trim() ? JSON.parse(text) : {};
7732
+ return Number(raw.counts?.posts) || 0;
7733
+ }
7734
+ function cutoutMap(spec) {
7735
+ const cutouts = new Map;
7736
+ for (const entry of (spec ?? "").split(/\s+/).filter(Boolean)) {
7737
+ const at2 = entry.indexOf("=");
7738
+ if (at2 > 0) {
7739
+ cutouts.set(entry.slice(0, at2), entry.slice(at2 + 1).split(",").filter(Boolean));
7740
+ }
7741
+ }
7742
+ return cutouts;
7743
+ }
7744
+ function hostMap(spec) {
7745
+ const hosts = new Map;
7746
+ for (const entry of (spec ?? "").split(/[\s,]+/).filter(Boolean)) {
7747
+ const at2 = entry.indexOf("=");
7748
+ const key = entry.slice(0, at2);
7749
+ try {
7750
+ const url = new URL(entry.slice(at2 + 1));
7751
+ if (key && url.protocol === "https:") {
7752
+ hosts.set(key, url.origin);
7753
+ }
7754
+ } catch {}
7755
+ }
7756
+ return hosts;
7757
+ }
7758
+ function chosen(value, all, fallback, none) {
7759
+ const words = (value ?? "").split(/\s+/);
7760
+ if (none !== undefined && words.includes(none)) {
7761
+ return [];
7762
+ }
7763
+ const picked = all.filter((item) => words.includes(item));
7764
+ return picked.length > 0 ? picked : [...fallback];
7765
+ }
7766
+ function ratingSet(value) {
7767
+ return chosen(value, RATINGS, ["safe"]);
7768
+ }
7769
+ function blockSet(value) {
7770
+ return chosen(value, BLOCKS, BLOCKS, "none");
7771
+ }
7772
+ function tiers(safe, questionable, explicit) {
7773
+ return { safe: new Set(safe), questionable: new Set(questionable), explicit: new Set(explicit) };
7774
+ }
7775
+ function based(spec) {
7776
+ const origin = HOSTS.get(spec.key);
7777
+ const cutouts = CUTOUTS.get(spec.key);
7428
7778
  return {
7429
- key,
7430
- name,
7431
- origin,
7432
- cutouts,
7433
- vouched,
7434
- ansi,
7779
+ ...spec,
7780
+ origin: origin ?? spec.origin,
7781
+ moved: origin !== undefined && origin !== spec.origin,
7782
+ cutouts: cutouts ?? spec.cutouts,
7783
+ vouched: spec.vouched && cutouts === undefined
7784
+ };
7785
+ }
7786
+ function anyOf(tags, grouped) {
7787
+ if (tags.length < 2) {
7788
+ return tags.join("");
7789
+ }
7790
+ return grouped ? `( ${tags.join(" ~ ")} )` : tags.map((tag) => `~${tag}`).join(" ");
7791
+ }
7792
+ function gelbooru(raw) {
7793
+ const spec = based(raw);
7794
+ const api = (params) => `${spec.origin}/index.php?${new URLSearchParams({ page: "dapi", s: "post", q: "index", ...params })}`;
7795
+ return {
7796
+ ...spec,
7797
+ cutouts: anyOf(spec.cutouts, true),
7798
+ ratings: tiers(["safe", "general"], ["questionable"], ["explicit"]),
7799
+ rate: () => "",
7800
+ best: "sort:score:desc",
7801
+ tagBudget: Number.POSITIVE_INFINITY,
7435
7802
  postsUrl: (tags, page) => api({ json: "1", limit: String(PAGE), pid: String(page), tags }),
7436
7803
  countUrl: (tags) => api({ limit: "0", tags }),
7437
7804
  postUrl: (id) => api({ json: "1", id: String(id) }),
7438
- pageUrl: (id) => `${origin}/index.php?page=post&s=view&id=${id}`,
7439
- parse: (text) => parseGelbooru(text, origin)
7805
+ pageUrl: (id) => `${spec.origin}/index.php?page=post&s=view&id=${id}`,
7806
+ parse: (text) => parseGelbooru(text, spec.origin),
7807
+ count: parseCount
7440
7808
  };
7441
7809
  }
7442
- function moebooru(key, name, origin, cutouts, vouched, ansi) {
7443
- const safe = (tags) => `${tags} rating:s`;
7810
+ function moebooru(raw) {
7811
+ const spec = based(raw);
7812
+ const params = (rest) => new URLSearchParams({ api_version: "2", include_tags: "1", ...rest });
7444
7813
  return {
7445
- key,
7446
- name,
7447
- origin,
7448
- cutouts,
7449
- vouched,
7450
- ansi,
7451
- postsUrl: (tags, page) => `${origin}/post.json?${new URLSearchParams({ limit: String(PAGE), page: String(page + 1), tags: safe(tags) })}`,
7452
- countUrl: (tags) => `${origin}/post.xml?${new URLSearchParams({ limit: "1", tags: safe(tags) })}`,
7453
- postUrl: (id) => `${origin}/post.json?${new URLSearchParams({ tags: `id:${id}` })}`,
7454
- pageUrl: (id) => `${origin}/post/show/${id}`,
7455
- parse: parseMoebooru
7814
+ ...spec,
7815
+ cutouts: anyOf(spec.cutouts, false),
7816
+ ratings: tiers([MOEBOORU.safe], [MOEBOORU.questionable], [MOEBOORU.explicit]),
7817
+ rate: (levels) => {
7818
+ const [only] = levels;
7819
+ if (levels.length === 1 && only) {
7820
+ return `rating:${MOEBOORU[only]}`;
7821
+ }
7822
+ const missing = RATINGS.filter((level) => !levels.includes(level));
7823
+ const [left] = missing;
7824
+ return missing.length === 1 && left ? `-rating:${MOEBOORU[left]}` : "";
7825
+ },
7826
+ best: "order:score",
7827
+ tagBudget: Number.POSITIVE_INFINITY,
7828
+ postsUrl: (tags, page) => `${spec.origin}/post.json?${params({ limit: String(PAGE), page: String(page + 1), tags })}`,
7829
+ countUrl: (tags) => `${spec.origin}/post.xml?${new URLSearchParams({ limit: "1", tags })}`,
7830
+ postUrl: (id) => `${spec.origin}/post.json?${params({ tags: `id:${id}` })}`,
7831
+ pageUrl: (id) => `${spec.origin}/post/show/${id}`,
7832
+ parse: parseMoebooru,
7833
+ count: parseCount
7834
+ };
7835
+ }
7836
+ function danbooru(raw) {
7837
+ const spec = based(raw);
7838
+ return {
7839
+ ...spec,
7840
+ cutouts: anyOf(spec.cutouts, false),
7841
+ ratings: tiers(["g"], ["s", "q"], ["e"]),
7842
+ rate: () => "",
7843
+ best: "order:score",
7844
+ tagBudget: 2,
7845
+ postsUrl: (tags, page) => `${spec.origin}/posts.json?${new URLSearchParams({ limit: String(PAGE), page: String(page + 1), tags })}`,
7846
+ countUrl: (tags) => `${spec.origin}/counts/posts.json?${new URLSearchParams({ tags })}`,
7847
+ postUrl: (id) => `${spec.origin}/posts.json?${new URLSearchParams({ limit: "1", tags: `id:${id}` })}`,
7848
+ pageUrl: (id) => `${spec.origin}/posts/${id}`,
7849
+ parse: parseDanbooru,
7850
+ count: parseCounts
7456
7851
  };
7457
7852
  }
7853
+ function siteNamed(name) {
7854
+ return SITES.find((site) => site.key === name || site.name === name || new URL(site.origin).host === name);
7855
+ }
7856
+ function tagsOf(query) {
7857
+ return query.split(/\s+/).filter(Boolean).length;
7858
+ }
7859
+ function postRef(text, fallback) {
7860
+ const query = text.trim();
7861
+ if (/^\d+$/.test(query)) {
7862
+ return { site: fallback, id: Number(query) };
7863
+ }
7864
+ const named = /^([\w.]+):(\d+)$/.exec(query);
7865
+ if (named) {
7866
+ const site2 = SITES.find((s) => s.key === named[1] || s.name === named[1]);
7867
+ return site2 && { site: site2, id: Number(named[2]) };
7868
+ }
7869
+ let url;
7870
+ try {
7871
+ url = new URL(query);
7872
+ } catch {
7873
+ return;
7874
+ }
7875
+ const site = siteNamed(url.host);
7876
+ const id = Number(url.searchParams.get("id") ?? url.pathname.split("/").filter(Boolean).at(-1));
7877
+ return site && id > 0 ? { site, id } : undefined;
7878
+ }
7458
7879
  var SITES = [
7459
- gelbooru("safebooru", "safebooru", "https://safebooru.org", "( transparent_background ~ vector_trace )", false, 4),
7460
- moebooru("yande", "yande.re", "https://yande.re", "transparent_png", true, 5),
7461
- moebooru("konachan", "konachan", "https://konachan.net", "~transparent ~vector", false, 6)
7880
+ gelbooru({
7881
+ key: "safebooru",
7882
+ name: "safebooru",
7883
+ origin: "https://safebooru.org",
7884
+ cutouts: ["transparent_background", "vector_trace"],
7885
+ vouched: false,
7886
+ ansi: 4
7887
+ }),
7888
+ moebooru({
7889
+ key: "yande",
7890
+ name: "yande.re",
7891
+ origin: "https://yande.re",
7892
+ cutouts: ["transparent_png"],
7893
+ vouched: true,
7894
+ ansi: 5
7895
+ }),
7896
+ moebooru({
7897
+ key: "konachan",
7898
+ name: "konachan",
7899
+ origin: "https://konachan.net",
7900
+ cutouts: ["transparent", "vector"],
7901
+ vouched: false,
7902
+ ansi: 6
7903
+ }),
7904
+ danbooru({
7905
+ key: "danbooru",
7906
+ name: "danbooru",
7907
+ origin: "https://safebooru.donmai.us",
7908
+ cutouts: ["transparent_background"],
7909
+ vouched: false,
7910
+ ansi: 2
7911
+ })
7462
7912
  ];
7463
7913
  function originHost(source) {
7464
7914
  let host;
@@ -7469,8 +7919,17 @@ function originHost(source) {
7469
7919
  }
7470
7920
  return host.split(".").slice(-2).join(".");
7471
7921
  }
7472
- function safe(post2) {
7473
- return SAFE.has(post2.rating);
7922
+ function exposed(post2, blocks = BLOCKS) {
7923
+ return post2.tags.filter((tag) => blocks.some((block2) => EXPOSED[block2].has(tag)));
7924
+ }
7925
+ function rated(site, post2, levels = ["safe"]) {
7926
+ return levels.some((level) => site.ratings[level].has(post2.rating));
7927
+ }
7928
+ function rendition(post2) {
7929
+ return [post2, ...post2.smaller].find((v) => v.width * v.height <= MAX_PIXELS);
7930
+ }
7931
+ function mirrored(owner) {
7932
+ return MIRRORS.has(owner);
7474
7933
  }
7475
7934
  function mates(owners) {
7476
7935
  const byOwner = new Map;
@@ -7482,24 +7941,83 @@ function mates(owners) {
7482
7941
  }
7483
7942
  return byOwner;
7484
7943
  }
7944
+ function cacheRoot() {
7945
+ return join4(process.env.XDG_CACHE_HOME ?? join4(homedir(), ".cache"), "ttheme");
7946
+ }
7485
7947
  function cacheDir(site) {
7486
- return join4(process.env.XDG_CACHE_HOME ?? join4(homedir(), ".cache"), "ttheme", site.key);
7948
+ return join4(cacheRoot(), site.key);
7949
+ }
7950
+ function sweepCache(limit = CACHE_BYTES, root2 = cacheRoot()) {
7951
+ const files = [];
7952
+ let total = 0;
7953
+ for (const site of listing(root2)) {
7954
+ for (const kind of HELD) {
7955
+ const dir = join4(root2, site, kind);
7956
+ for (const name of listing(dir)) {
7957
+ const path2 = join4(dir, name);
7958
+ try {
7959
+ const { size, mtimeMs } = statSync2(path2);
7960
+ files.push({ path: path2, size, at: mtimeMs });
7961
+ total += size;
7962
+ } catch {}
7963
+ }
7964
+ }
7965
+ }
7966
+ files.sort((a, b) => a.at - b.at);
7967
+ for (const file of files) {
7968
+ if (total <= limit) {
7969
+ return;
7970
+ }
7971
+ try {
7972
+ rmSync3(file.path, { force: true });
7973
+ total -= file.size;
7974
+ } catch {}
7975
+ }
7976
+ }
7977
+ function retryAfter(value, now) {
7978
+ if (value !== null && /^\s*\d+\s*$/.test(value)) {
7979
+ return Number(value) * 1000;
7980
+ }
7981
+ const at2 = value === null ? Number.NaN : Date.parse(value);
7982
+ return Number.isNaN(at2) ? MAX_WAIT : Math.max(0, at2 - now);
7983
+ }
7984
+ function pausedUntil(site) {
7985
+ return paused.get(site.key) ?? 0;
7986
+ }
7987
+ function challenged(response) {
7988
+ return (response.headers.get("server") ?? "").startsWith("cloudflare") && response.headers.get("cf-mitigated") === "challenge";
7487
7989
  }
7488
7990
  async function get(site, url, signal, headers = {}, timeout = TIMEOUT) {
7489
- const response = await fetch(url, {
7490
- headers: { "User-Agent": AGENT, Referer: `${site.origin}/`, ...headers },
7491
- signal: timeout ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
7492
- });
7493
- if (!response.ok) {
7494
- throw new Error(`${site.name} answered ${response.status}`);
7991
+ for (let tries = 1;; tries++) {
7992
+ const wait = pausedUntil(site) - Date.now();
7993
+ if (wait > 0) {
7994
+ await sleep(wait, undefined, { signal });
7995
+ }
7996
+ const response = await fetch(url, {
7997
+ headers: { "User-Agent": AGENT, Referer: `${site.origin}/`, ...headers },
7998
+ signal: timeout ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
7999
+ });
8000
+ if (response.status === 429 && tries < TRIES) {
8001
+ await response.body?.cancel();
8002
+ const delay = retryAfter(response.headers.get("retry-after"), Date.now());
8003
+ if (delay > MAX_WAIT) {
8004
+ throw new Error(`${site.name} asks to wait ${Math.ceil(delay / 1000)}s`);
8005
+ }
8006
+ paused.set(site.key, Math.max(pausedUntil(site), Date.now() + delay));
8007
+ continue;
8008
+ }
8009
+ if (!response.ok) {
8010
+ await response.body?.cancel();
8011
+ throw new Error(challenged(response) ? `${site.name} is behind a Cloudflare challenge` : `${site.name} answered ${response.status}`);
8012
+ }
8013
+ return response;
7495
8014
  }
7496
- return response;
7497
8015
  }
7498
8016
  async function fetchPosts(site, tags, page, signal) {
7499
8017
  return site.parse(await (await get(site, site.postsUrl(tags, page), signal)).text());
7500
8018
  }
7501
8019
  async function fetchCount(site, tags, signal) {
7502
- return parseCount(await (await get(site, site.countUrl(tags), signal)).text());
8020
+ return site.count(await (await get(site, site.countUrl(tags), signal)).text());
7503
8021
  }
7504
8022
  async function fetchPost(site, id, signal) {
7505
8023
  const [found] = site.parse(await (await get(site, site.postUrl(id), signal)).text());
@@ -7537,7 +8055,7 @@ async function fetchBytes(site, url, signal, progress) {
7537
8055
  }
7538
8056
 
7539
8057
  // src/catalog.ts
7540
- import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
8058
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
7541
8059
  import { dirname, join as join5 } from "node:path";
7542
8060
  var REGISTRY_URL = "https://kecan0406.github.io/ttheme/manifest.json";
7543
8061
  function catalogPath(configHome) {
@@ -7554,7 +8072,7 @@ function readCatalog(configHome) {
7554
8072
  if (!existsSync(path2)) {
7555
8073
  throw new Error(`no catalog at ${path2} — run \`ttheme init\` first`);
7556
8074
  }
7557
- return parseCatalog(readFileSync2(path2, "utf8"));
8075
+ return parseCatalog(readFileSync3(path2, "utf8"));
7558
8076
  }
7559
8077
  function parseCatalog(source) {
7560
8078
  let doc;
@@ -7614,8 +8132,61 @@ function search(palettes, query) {
7614
8132
  return palettes.filter((p) => [p.name, p.group, p.native ?? "", p.ansiSource].some((field) => field.toLowerCase().includes(needle)));
7615
8133
  }
7616
8134
 
8135
+ // src/cutout.ts
8136
+ import { spawn } from "node:child_process";
8137
+ var TIMEOUT2 = 30000;
8138
+ var LEAST = 3;
8139
+ var MOST = 97;
8140
+ var SCRIPT = `ObjC.import('Vision')
8141
+ ObjC.import('CoreImage')
8142
+ ObjC.import('CoreGraphics')
8143
+ function run(argv) {
8144
+ const error = Ref()
8145
+ const image = $.CIImage.imageWithContentsOfURL($.NSURL.fileURLWithPath(argv[0]))
8146
+ if (!image) throw new Error('unreadable image')
8147
+ const handler = $.VNImageRequestHandler.alloc.initWithCIImageOptions(image, $({}))
8148
+ const request = $.VNGenerateForegroundInstanceMaskRequest.alloc.init
8149
+ if (!handler.performRequestsError($([request]), error)) throw new Error('vision failed')
8150
+ if (request.results.count === 0) throw new Error('no foreground')
8151
+ const found = request.results.objectAtIndex(0)
8152
+ const buffer = found.generateMaskedImageOfInstancesFromRequestHandlerCroppedToInstancesExtentError(
8153
+ found.allInstances, handler, false, error)
8154
+ if (!buffer) throw new Error('mask failed')
8155
+ const written = $.CIContext.context.writePNGRepresentationOfImageToURLFormatColorSpaceOptionsError(
8156
+ $.CIImage.imageWithCVPixelBuffer(buffer), $.NSURL.fileURLWithPath(argv[1]), $.kCIFormatRGBA8,
8157
+ $.CGColorSpaceCreateWithName($.kCGColorSpaceSRGB), $({}), error)
8158
+ if (!written) throw new Error('write failed')
8159
+ }`;
8160
+ function canRemoveBackground() {
8161
+ return process.platform === "darwin";
8162
+ }
8163
+ function keepable(clear) {
8164
+ return clear >= LEAST && clear <= MOST;
8165
+ }
8166
+ function removeBackground(input, output, signal) {
8167
+ return new Promise((resolve, reject) => {
8168
+ const child = spawn("osascript", ["-l", "JavaScript", "-e", SCRIPT, input, output], {
8169
+ stdio: ["ignore", "ignore", "pipe"],
8170
+ signal: AbortSignal.any([signal, AbortSignal.timeout(TIMEOUT2)])
8171
+ });
8172
+ let failure = "";
8173
+ child.stderr.on("data", (chunk) => {
8174
+ failure += chunk;
8175
+ });
8176
+ child.on("error", reject);
8177
+ child.on("close", (code) => {
8178
+ if (code === 0) {
8179
+ resolve();
8180
+ return;
8181
+ }
8182
+ reject(new Error(failure.trim().split(`
8183
+ `).at(-1) || `osascript exited ${code}`));
8184
+ });
8185
+ });
8186
+ }
8187
+
7617
8188
  // src/find-screen.ts
7618
- var TILE = { pitch: 25, cols: 22, rows: 9, height: 12 };
8189
+ var TILE = { pitch: 25, cols: 22, rows: 9, height: 13 };
7619
8190
  var MIN = { cols: 25, rows: 16 };
7620
8191
  var TRY_ID = 2 ** 31;
7621
8192
  var BELOW_BG = -1073741826;
@@ -7652,14 +8223,14 @@ function decodeKeys(input) {
7652
8223
  if (m) {
7653
8224
  keys.push(CSI[`${m[1] ?? ""}${m[2] ?? m[3] ?? ""}`] ?? "nop");
7654
8225
  i += 1 + m[0].length;
7655
- } else {
7656
- keys.push("esc");
7657
- i++;
8226
+ continue;
7658
8227
  }
8228
+ keys.push("esc");
8229
+ i++;
7659
8230
  continue;
7660
8231
  }
7661
8232
  keys.push(c === "\r" || c === `
7662
- ` ? "enter" : c === "\t" ? "tab" : c === "\x03" ? "ctrl-c" : c);
8233
+ ` ? "enter" : c === "\t" ? "tab" : c === "\x03" ? "ctrl-c" : c === "" || c === "\b" ? "backspace" : c);
7663
8234
  i++;
7664
8235
  }
7665
8236
  return keys;
@@ -7730,8 +8301,14 @@ function progress(got, size) {
7730
8301
  }
7731
8302
  return size >= 1e6 ? `${(got / 1e6).toFixed(1)}/${(size / 1e6).toFixed(1)} MB` : `${Math.round(got / 1000)}/${Math.round(size / 1000)} KB`;
7732
8303
  }
8304
+ function plain(artist) {
8305
+ return artist.replace(/_\([^)]*\)?$/, "");
8306
+ }
7733
8307
  function dims(tile) {
7734
- return [`${tile.width}×${tile.height}`, Math.max(tile.width, tile.height) < SMALL ? YELLOW : D];
8308
+ return [
8309
+ `${tile.reduced ? "↓" : ""}${tile.width}×${tile.height}`,
8310
+ Math.max(tile.width, tile.height) < SMALL ? YELLOW : D
8311
+ ];
7735
8312
  }
7736
8313
  function foot(line, cols, accent, spec) {
7737
8314
  const keys = [...spec.keys ?? []];
@@ -7854,23 +8431,61 @@ function frameBox(lines, r0, c0, h, w, sgr) {
7854
8431
  }
7855
8432
  lines[r0 + h - 1]?.put(c0, `╰${"─".repeat(w - 2)}╯`, sgr);
7856
8433
  }
7857
- function badge(view) {
7858
- return [` ${view.site} `, `\x1B[7;${30 + view.siteAnsi}m`];
8434
+ function badge(view, label = view.site) {
8435
+ return [` ${label} `, `\x1B[7;${30 + view.siteAnsi}m`];
8436
+ }
8437
+ function tabs(line, cols, view) {
8438
+ const strip = SITES.flatMap((site, i) => {
8439
+ const label = `${site.name}${site.moved ? "*" : ""}`;
8440
+ const tab = site.name === view.site ? badge(view, label) : [` ${label} `, D];
8441
+ return i ? [[" ", ""], tab] : [tab];
8442
+ });
8443
+ const fits = strip.reduce((n, [text]) => n + width(text), 0) <= cols;
8444
+ line.run(0, fits ? strip : [badge(view)]);
7859
8445
  }
7860
8446
  function query(line, cols, view, accent) {
8447
+ if (view.editing !== undefined) {
8448
+ const c2 = line.run(0, [
8449
+ ["⌕ ", accent],
8450
+ [view.editing, ""],
8451
+ ["█", accent]
8452
+ ]);
8453
+ line.put(c2, view.editing ? " enter searches" : ` a tag, a post url or an id — ${view.tag || "esc leaves"}`, D);
8454
+ return;
8455
+ }
7861
8456
  const c = line.run(0, [
7862
8457
  ["⌕ ", accent],
7863
- [view.tag, ""]
8458
+ [view.tag || "nothing yet — / searches", view.tag ? "" : D]
7864
8459
  ]);
7865
- line.put(c, ` ${view.preset}`, D);
8460
+ const allowed = BLOCKS.filter((block2) => !view.block.includes(block2));
8461
+ const state = [
8462
+ view.preset,
8463
+ view.order,
8464
+ ...view.rating.join("+") === "safe" ? [] : [view.rating.join("+")],
8465
+ ...allowed.length > 0 ? [`allows ${allowed.join("+")}`] : [],
8466
+ ...view.unblocked ? ["unblock"] : []
8467
+ ];
8468
+ line.put(c, ` ${state.join(" ")}`, D);
7866
8469
  if (view.total > 0 || !view.searching) {
7867
- const counter = [badge(view), [` ${view.tiles.length}/${view.checked}`, ""]];
8470
+ const counter = [[`${view.tiles.length}/${view.checked}`, ""]];
7868
8471
  if (view.checked < view.total) {
7869
8472
  counter.push([` of ${view.total}`, D]);
7870
8473
  }
7871
8474
  line.right(cols, counter);
7872
8475
  }
7873
8476
  }
8477
+ function transparent(shown) {
8478
+ if (shown.cut === "on") {
8479
+ return [[`cut out · ${shown.clear}% transparent`, GREEN]];
8480
+ }
8481
+ if (shown.cut === "off") {
8482
+ return [["opaque · x cuts out", YELLOW]];
8483
+ }
8484
+ if (shown.cut === "failed") {
8485
+ return [["opaque · no cut-out", YELLOW]];
8486
+ }
8487
+ return [shown.clear > 0 ? [`${shown.clear}% transparent`, GREEN] : ["opaque", YELLOW]];
8488
+ }
7874
8489
  function status(view) {
7875
8490
  if (view.installing !== undefined) {
7876
8491
  return [`installing ${view.palette} ← ${view.site} ${view.installing}`, YELLOW];
@@ -7878,18 +8493,27 @@ function status(view) {
7878
8493
  if (view.error) {
7879
8494
  return [view.error, YELLOW];
7880
8495
  }
8496
+ if (view.waiting !== undefined) {
8497
+ return [`${view.site} asked to slow down · ${view.waiting}s`, YELLOW];
8498
+ }
7881
8499
  if (view.fetching) {
7882
8500
  return [`fetching ${view.fetching.id} · ${progress(view.fetching.got, view.fetching.size)}`, YELLOW];
7883
8501
  }
8502
+ if (view.cutting !== undefined) {
8503
+ return [`cutting out ${view.cutting}`, YELLOW];
8504
+ }
7884
8505
  if (view.preparing !== undefined) {
7885
8506
  return [`preparing ${view.preparing}`, YELLOW];
7886
8507
  }
8508
+ if (view.note) {
8509
+ return [view.note, D];
8510
+ }
7887
8511
  return;
7888
8512
  }
7889
8513
  function grid(lines, images, cols, rows, view, accent) {
7890
8514
  const { perRow, rowsVis } = gridShape(cols, rows);
7891
8515
  query(lines[0], cols, view, accent);
7892
- lines[1]?.put(0, "─".repeat(Math.min(48, cols)), D);
8516
+ tabs(lines[1], cols, view);
7893
8517
  for (let k = 0;k < perRow * rowsVis; k++) {
7894
8518
  const i = view.top * perRow + k;
7895
8519
  const tile = view.tiles[i];
@@ -7909,6 +8533,20 @@ function grid(lines, images, cols, rows, view, accent) {
7909
8533
  if (tile.mates.length > 0) {
7910
8534
  lines[r0 + 10]?.put(c0 + TILE.cols, "≈", accent);
7911
8535
  }
8536
+ const credit = tile.artist ? plain(tile.artist) : tile.owner && `@${tile.owner}`;
8537
+ if (credit) {
8538
+ lines[r0 + 11]?.put(c0 + 1, credit.slice(0, TILE.cols - 8), D);
8539
+ }
8540
+ const marks = [];
8541
+ if (tile.score > 0) {
8542
+ marks.push([`★${tile.score}`, D]);
8543
+ }
8544
+ if (tile.variants > 1) {
8545
+ marks.push([` ×${tile.variants}`, accent]);
8546
+ }
8547
+ if (marks.length > 0) {
8548
+ lines[r0 + 11]?.right(c0 + TILE.cols + 1, marks);
8549
+ }
7912
8550
  }
7913
8551
  const above = view.top * perRow;
7914
8552
  const below = view.tiles.length - (view.top + rowsVis) * perRow;
@@ -7917,7 +8555,7 @@ function grid(lines, images, cols, rows, view, accent) {
7917
8555
  lines[rows - 2]?.put(0, scroll.join(" "), D);
7918
8556
  }
7919
8557
  if (!view.searching && view.tiles.length === 0 && !view.error) {
7920
- lines[3]?.put(0, view.preset === "cutouts" ? `no transparent cutouts of ${view.tag} on ${view.site} — tab searches every post, p tries ${view.nextSite}` : `no posts of ${view.tag} on ${view.site} — p tries ${view.nextSite}`, D);
8558
+ lines[3]?.put(0, view.preset === "cutouts" ? `no transparent cutouts of ${view.tag} on ${view.site} — c searches every post, tab tries ${view.nextSite}` : `no posts of ${view.tag} on ${view.site} — tab tries ${view.nextSite}`, D);
7921
8559
  }
7922
8560
  const line = lines[rows - 1];
7923
8561
  const lead = status(view);
@@ -7935,8 +8573,9 @@ function grid(lines, images, cols, rows, view, accent) {
7935
8573
  keys: [
7936
8574
  ["←↑↓→", "move"],
7937
8575
  ["enter", "try on"],
7938
- ["tab", view.preset === "cutouts" ? "all" : "cutouts"],
7939
- ["p", view.nextSite],
8576
+ ["tab", "site"],
8577
+ ["s", "settings"],
8578
+ ["/", "search"],
7940
8579
  ["?", "keys"]
7941
8580
  ],
7942
8581
  right: ["esc", "back"]
@@ -7949,6 +8588,12 @@ function trial(lines, images, cols, rows, view, accent) {
7949
8588
  }
7950
8589
  const shown = view.shown?.id === tile.id ? view.shown : undefined;
7951
8590
  const meta2 = [badge(view), [" ", ""], [String(tile.id), B], [" ", ""], dims(tile)];
8591
+ if (tile.artist) {
8592
+ meta2.push([` · ${plain(tile.artist)}`, ""]);
8593
+ }
8594
+ if (tile.score > 0) {
8595
+ meta2.push([` · ★${tile.score}`, D]);
8596
+ }
7952
8597
  if (shown) {
7953
8598
  meta2.push([` · ${megabytes(shown.bytes)}`, D]);
7954
8599
  }
@@ -7957,7 +8602,7 @@ function trial(lines, images, cols, rows, view, accent) {
7957
8602
  meta2.push([` · ${tile.origin}`, D]);
7958
8603
  }
7959
8604
  if (shown) {
7960
- meta2.push([" ", ""], shown.clear > 0 ? [`${shown.clear}% transparent`, GREEN] : ["opaque", YELLOW]);
8605
+ meta2.push([" ", ""], ...transparent(shown));
7961
8606
  }
7962
8607
  lines[0]?.run(0, meta2);
7963
8608
  if (tile.mates.length > 0) {
@@ -7977,6 +8622,7 @@ function trial(lines, images, cols, rows, view, accent) {
7977
8622
  keys: view.installing === undefined ? [
7978
8623
  ["←→", "browse"],
7979
8624
  ["enter", "install"],
8625
+ ...view.shown?.cut === "on" || view.shown?.cut === "off" ? [["x", "cut out"]] : [],
7980
8626
  ["?", "keys"]
7981
8627
  ] : [],
7982
8628
  right: view.installing === undefined ? ["esc", "grid"] : undefined
@@ -7986,18 +8632,60 @@ var KEYS = {
7986
8632
  grid: [
7987
8633
  ["move", "←↑↓→ home end pgup pgdn"],
7988
8634
  ["try on", "enter"],
7989
- ["posts", "tab cutouts or every post"],
7990
- ["site", `p ${SITES.map((site) => site.name).join(", ")}`],
8635
+ ["site", `tab ${SITES.map((site) => site.name).join(", ")}`],
8636
+ ["posts", "c cutouts or every post"],
8637
+ ["search", "/ a tag, a post url or an id"],
8638
+ ["unfold", "space a set of ×N"],
8639
+ ["open", "o the post page in a browser"],
8640
+ ["settings", "s rating, block, posts, order, sets, remove bg"],
7991
8641
  ["back", "esc returns to preview"],
7992
8642
  ["close", "? esc"]
7993
8643
  ],
7994
8644
  try: [
7995
8645
  ["browse", "←→"],
7996
8646
  ["install", "enter"],
8647
+ ["cut out", "x the background off or on, on an opaque picture"],
8648
+ ["open", "o the post page in a browser"],
7997
8649
  ["grid", "esc"],
7998
8650
  ["close", "? esc"]
7999
8651
  ]
8000
8652
  };
8653
+ function panel(lines, cols, rows, view, accent) {
8654
+ const at2 = view.panel ?? 0;
8655
+ const label = Math.max(...view.settings.map((row) => row.label.length));
8656
+ const widest = Math.max(...view.settings.map((row) => row.choices.reduce((n, c) => n + c.length + (row.multi ? 6 : 3), 0)));
8657
+ const w = Math.min(cols - 2, Math.max(28, label + widest + 8));
8658
+ const h = view.settings.length + 4;
8659
+ const x = Math.floor((cols - w) / 2);
8660
+ const y = Math.max(2, Math.floor((rows - h) / 2));
8661
+ lines[y]?.put(x, `╭─ settings ${"─".repeat(Math.max(0, w - 13))}╮`);
8662
+ for (let r = y + 1;r < y + h - 1; r++) {
8663
+ lines[r]?.put(x, `│${" ".repeat(w - 2)}│`);
8664
+ }
8665
+ view.settings.forEach((row, i) => {
8666
+ const line = lines[y + 2 + i];
8667
+ line.put(x + 3, row.label, i === at2 ? B : D);
8668
+ let c = x + 5 + label;
8669
+ const on = row.value.split(" ");
8670
+ row.choices.forEach((choice, k) => {
8671
+ const lit = on.includes(choice);
8672
+ const under = row.multi && i === at2 && k === row.cursor ? "4;" : "";
8673
+ const text = row.multi ? ` ${lit ? "[x]" : "[ ]"} ${choice} ` : ` ${choice} `;
8674
+ c = line.put(c, text, lit ? `\x1B[${under}7;${30 + view.siteAnsi}m` : under ? `\x1B[4m${D}` : D) + 1;
8675
+ });
8676
+ });
8677
+ lines[y + h - 1]?.put(x, `╰${"─".repeat(w - 2)}╯`);
8678
+ foot(lines[rows - 1], cols, accent, {
8679
+ badge: "SET",
8680
+ keys: [
8681
+ ["↑↓", "setting"],
8682
+ ["←→", "value"],
8683
+ ["space", "toggle"],
8684
+ ["enter", "save"]
8685
+ ],
8686
+ right: ["esc", "undo"]
8687
+ });
8688
+ }
8001
8689
  function help(lines, cols, rows, view, accent) {
8002
8690
  const keys = KEYS[view.mode];
8003
8691
  const w = Math.min(cols - 2, 50);
@@ -8030,19 +8718,23 @@ function renderFind(view, cols, rows) {
8030
8718
  } else {
8031
8719
  grid(lines, images, cols, rows, view, accent);
8032
8720
  }
8033
- if (view.help) {
8721
+ if (view.help || view.panel !== undefined) {
8034
8722
  const keep = view.mode === "try" ? images : [];
8035
8723
  for (let r = 0;r < rows; r++) {
8036
8724
  lines[r] = new Line(cols);
8037
8725
  }
8038
- help(lines, cols, rows, view, accent);
8726
+ if (view.help) {
8727
+ help(lines, cols, rows, view, accent);
8728
+ } else {
8729
+ panel(lines, cols, rows, view, accent);
8730
+ }
8039
8731
  return { lines: lines.map((l) => l.render()), images: keep };
8040
8732
  }
8041
8733
  return { lines: lines.map((l) => l.render()), images };
8042
8734
  }
8043
8735
 
8044
8736
  // src/palettes.ts
8045
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
8737
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync as rmSync4, writeFileSync as writeFileSync3 } from "node:fs";
8046
8738
  import { homedir as homedir2 } from "node:os";
8047
8739
  import { dirname as dirname2, join as join6 } from "node:path";
8048
8740
  var EMITTERS = { ghostty, kitty, alacritty };
@@ -8057,7 +8749,7 @@ function readInstalled(configHome2) {
8057
8749
  if (!existsSync2(path2)) {
8058
8750
  throw new Error(`nothing installed yet at ${path2} — run \`ttheme init\` first`);
8059
8751
  }
8060
- const doc = JSON.parse(readFileSync3(path2, "utf8"));
8752
+ const doc = JSON.parse(readFileSync4(path2, "utf8"));
8061
8753
  if (!Array.isArray(doc?.palettes) || !Array.isArray(doc.terminals)) {
8062
8754
  throw new Error(`${path2} has no palettes or terminals`);
8063
8755
  }
@@ -8152,7 +8844,7 @@ function sync(configHome2, catalog, state) {
8152
8844
  }
8153
8845
  }
8154
8846
  const { file, body } = blockFor(terminal, configHome2, startup);
8155
- const current = existsSync2(file) ? readFileSync3(file, "utf8") : "";
8847
+ const current = existsSync2(file) ? readFileSync4(file, "utf8") : "";
8156
8848
  if (terminal === "alacritty" && current !== "" && !current.includes("# ttheme begin")) {
8157
8849
  continue;
8158
8850
  }
@@ -8170,7 +8862,7 @@ function forget(configHome2, catalog, terminals, names) {
8170
8862
  for (const { file } of themeFiles(terminal, toTheme(entry, catalog))) {
8171
8863
  const path2 = join6(configHome2, terminal, "themes", file);
8172
8864
  if (existsSync2(path2)) {
8173
- rmSync3(path2, { force: true });
8865
+ rmSync4(path2, { force: true });
8174
8866
  removed.push(path2);
8175
8867
  }
8176
8868
  }
@@ -8179,32 +8871,310 @@ function forget(configHome2, catalog, terminals, names) {
8179
8871
  return removed;
8180
8872
  }
8181
8873
 
8182
- // src/find.ts
8183
- var WORKERS = 4;
8184
- var SETTLE = 150;
8185
- var TRY_WIDTH = 1280;
8186
- var LOOKAHEAD = 1;
8187
- function describe(error) {
8188
- if (!(error instanceof Error)) {
8189
- return String(error);
8874
+ // src/render.ts
8875
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
8876
+ import { dirname as dirname3 } from "node:path";
8877
+ import { isMainThread, parentPort, Worker } from "node:worker_threads";
8878
+ var LANES = { thumb: "tile", show: "view", backdrop: "view" };
8879
+ function work(task) {
8880
+ const image = decodeImage(new Uint8Array(readFileSync5(task.from)), MAX_PIXELS);
8881
+ if (task.job === "backdrop") {
8882
+ installBackdrop(task.home, task.colors, task.tone, image, {
8883
+ ...task.origin,
8884
+ bytes: new Uint8Array(readFileSync5(task.source))
8885
+ });
8886
+ return 0;
8190
8887
  }
8191
- if (error instanceof AggregateError && error.errors[0]) {
8192
- return describe(error.errors[0]);
8888
+ if (task.job === "thumb") {
8889
+ mkdirSync4(dirname3(task.to), { recursive: true });
8890
+ writeFileSync4(task.to, encodePng(contain(image, task.width, task.height)));
8891
+ return 0;
8193
8892
  }
8194
- if (error.cause) {
8195
- return describe(error.cause);
8893
+ const clear = transparency(image);
8894
+ mkdirSync4(dirname3(task.to), { recursive: true });
8895
+ writeFileSync4(task.to, encodePng(tryOn(image, task.colors, task.tone, task.width, task.height, clear)));
8896
+ return clear;
8897
+ }
8898
+ function serveRenders(port) {
8899
+ port.on("message", (task) => {
8900
+ try {
8901
+ port.postMessage({ id: task.id, value: work(task) });
8902
+ } catch (error) {
8903
+ port.postMessage({ id: task.id, error: error instanceof Error ? error.message : String(error) });
8904
+ }
8905
+ });
8906
+ }
8907
+
8908
+ class Line2 {
8909
+ worker;
8910
+ next = 1;
8911
+ waiting = new Map;
8912
+ send(task) {
8913
+ const id = this.next++;
8914
+ return new Promise((ok, no) => {
8915
+ const worker = this.open();
8916
+ this.waiting.set(id, { ok, no });
8917
+ worker.ref();
8918
+ worker.postMessage({ ...task, id });
8919
+ });
8920
+ }
8921
+ close() {
8922
+ this.worker?.terminate();
8923
+ this.worker = undefined;
8924
+ this.drop(new Error("renderer closed"));
8925
+ }
8926
+ open() {
8927
+ if (this.worker) {
8928
+ return this.worker;
8929
+ }
8930
+ const worker = new Worker(import.meta.filename);
8931
+ worker.unref();
8932
+ worker.on("message", ({ id, value, error }) => {
8933
+ const held = this.waiting.get(id);
8934
+ this.waiting.delete(id);
8935
+ if (this.waiting.size === 0) {
8936
+ worker.unref();
8937
+ }
8938
+ if (error !== undefined) {
8939
+ held?.no(new Error(error));
8940
+ } else {
8941
+ held?.ok(value);
8942
+ }
8943
+ });
8944
+ const lost = (error) => {
8945
+ if (this.worker === worker) {
8946
+ this.worker = undefined;
8947
+ }
8948
+ this.drop(error instanceof Error ? error : new Error("renderer stopped"));
8949
+ };
8950
+ worker.on("error", lost);
8951
+ worker.on("exit", () => lost(undefined));
8952
+ this.worker = worker;
8953
+ return worker;
8954
+ }
8955
+ drop(error) {
8956
+ for (const held of this.waiting.values()) {
8957
+ held.no(error);
8958
+ }
8959
+ this.waiting.clear();
8196
8960
  }
8197
- const code = error.code;
8198
- return typeof code === "string" ? code : error.message || error.name;
8199
8961
  }
8200
- function reason(site, error) {
8201
- return `${site.name}: ${describe(error)}`;
8962
+
8963
+ class Renderer {
8964
+ lines = new Map;
8965
+ run(task) {
8966
+ const lane = LANES[task.job];
8967
+ let line = this.lines.get(lane);
8968
+ if (!line) {
8969
+ line = new Line2;
8970
+ this.lines.set(lane, line);
8971
+ }
8972
+ return line.send(task);
8973
+ }
8974
+ close() {
8975
+ for (const line of this.lines.values()) {
8976
+ line.close();
8977
+ }
8978
+ this.lines.clear();
8979
+ }
8202
8980
  }
8203
- function tick() {
8204
- return new Promise((resolve2) => setImmediate(resolve2));
8981
+ if (!isMainThread && parentPort) {
8982
+ serveRenders(parentPort);
8205
8983
  }
8206
8984
 
8207
- class Finder {
8985
+ // src/unblock.ts
8986
+ import { connect, createServer } from "node:net";
8987
+ var SPLIT = 20;
8988
+ var HANDSHAKE = 22;
8989
+ function joined(parts) {
8990
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.length, 0));
8991
+ let at2 = 0;
8992
+ for (const part of parts) {
8993
+ out.set(part, at2);
8994
+ at2 += part.length;
8995
+ }
8996
+ return out;
8997
+ }
8998
+ function reframe(record, at2 = SPLIT) {
8999
+ const body = record.subarray(5);
9000
+ if (record[0] !== HANDSHAKE || body.length < 2) {
9001
+ return [record];
9002
+ }
9003
+ const cut = Math.max(1, Math.min(at2, body.length - 1));
9004
+ const framed = (part) => joined([
9005
+ Uint8Array.from([
9006
+ record[0],
9007
+ record[1],
9008
+ record[2],
9009
+ part.length >> 8,
9010
+ part.length & 255
9011
+ ]),
9012
+ part
9013
+ ]);
9014
+ return [framed(body.subarray(0, cut)), framed(body.subarray(cut))];
9015
+ }
9016
+ function tunnelTo(client, host, port, rest) {
9017
+ const upstream = connect(port, host, () => {
9018
+ upstream.setNoDelay(true);
9019
+ client.write(`HTTP/1.1 200 Connection Established\r
9020
+ \r
9021
+ `);
9022
+ let held = rest;
9023
+ let framed = false;
9024
+ const forward = (chunk) => {
9025
+ if (framed) {
9026
+ upstream.write(chunk);
9027
+ return;
9028
+ }
9029
+ held = joined([held, chunk]);
9030
+ if (held.length < 5) {
9031
+ return;
9032
+ }
9033
+ const length = held[3] * 256 + held[4];
9034
+ if (held[0] !== HANDSHAKE) {
9035
+ framed = true;
9036
+ upstream.write(held);
9037
+ return;
9038
+ }
9039
+ if (held.length < 5 + length) {
9040
+ return;
9041
+ }
9042
+ framed = true;
9043
+ const [first, second] = reframe(held.subarray(0, 5 + length));
9044
+ upstream.write(first);
9045
+ setTimeout(() => {
9046
+ if (second) {
9047
+ upstream.write(second);
9048
+ }
9049
+ upstream.write(held.subarray(5 + length));
9050
+ }, 10);
9051
+ };
9052
+ client.on("data", forward);
9053
+ if (held.length > 0) {
9054
+ const pending = held;
9055
+ held = new Uint8Array(0);
9056
+ forward(pending);
9057
+ }
9058
+ upstream.pipe(client);
9059
+ });
9060
+ upstream.on("error", () => client.destroy());
9061
+ client.on("error", () => upstream.destroy());
9062
+ }
9063
+ function serve(client) {
9064
+ client.setNoDelay(true);
9065
+ let head = new Uint8Array(0);
9066
+ const onData = (chunk) => {
9067
+ head = joined([head, chunk]);
9068
+ const text = Buffer.from(head).toString("latin1");
9069
+ const end = text.indexOf(`\r
9070
+ \r
9071
+ `);
9072
+ if (end === -1) {
9073
+ return;
9074
+ }
9075
+ client.off("data", onData);
9076
+ const target = /^CONNECT ([^ :]+):(\d+)/.exec(text);
9077
+ if (!target) {
9078
+ client.end(`HTTP/1.1 405 Method Not Allowed\r
9079
+ \r
9080
+ `);
9081
+ return;
9082
+ }
9083
+ tunnelTo(client, target[1], Number(target[2]), head.subarray(end + 4));
9084
+ };
9085
+ client.on("data", onData);
9086
+ client.on("error", () => client.destroy());
9087
+ }
9088
+ function tunnel() {
9089
+ const server = createServer(serve);
9090
+ return new Promise((resolve2, reject) => {
9091
+ server.once("error", reject);
9092
+ server.listen(0, "127.0.0.1", () => {
9093
+ resolve2({
9094
+ port: server.address().port,
9095
+ close: () => {
9096
+ server.close();
9097
+ server.unref();
9098
+ }
9099
+ });
9100
+ });
9101
+ });
9102
+ }
9103
+
9104
+ // src/find.ts
9105
+ var SETTINGS = [
9106
+ { name: "TTHEME_FIND_RATING", label: "rating", choices: RATINGS, multi: { read: ratingSet } },
9107
+ { name: "TTHEME_FIND_BLOCK", label: "block", choices: BLOCKS, multi: { read: blockSet, none: "none" } },
9108
+ { name: "TTHEME_FIND_POSTS", label: "posts", choices: ["all", "cutouts"] },
9109
+ { name: "TTHEME_FIND_ORDER", label: "order", choices: ["newest", "score"] },
9110
+ { name: "TTHEME_FIND_SETS", label: "sets", choices: ["fold", "show"] },
9111
+ ...canRemoveBackground() ? [{ name: "TTHEME_FIND_REMOVE_BG", label: "remove bg", choices: ["on", "off"] }] : []
9112
+ ];
9113
+ function initial(setting, raw) {
9114
+ if (setting.multi) {
9115
+ return setting.multi.read(raw).join(" ") || setting.multi.none;
9116
+ }
9117
+ return setting.choices.find((choice) => choice === raw) ?? setting.choices[0];
9118
+ }
9119
+ var PROBE = 12;
9120
+ var THUMB = 12;
9121
+ var PRELOAD = 2;
9122
+ var SETTLE = 150;
9123
+ var ESCAPE = 30;
9124
+ var TRY_WIDTH = 1280;
9125
+ var LOOKAHEAD = 2;
9126
+ function blank(key, searching) {
9127
+ return {
9128
+ key,
9129
+ groups: [],
9130
+ sources: [],
9131
+ queue: [],
9132
+ seen: new Set,
9133
+ checked: 0,
9134
+ total: 0,
9135
+ focus: 0,
9136
+ top: 0,
9137
+ searching
9138
+ };
9139
+ }
9140
+ function describe(error) {
9141
+ if (!(error instanceof Error)) {
9142
+ return String(error);
9143
+ }
9144
+ if (error instanceof AggregateError && error.errors[0]) {
9145
+ return describe(error.errors[0]);
9146
+ }
9147
+ if (error.cause) {
9148
+ return describe(error.cause);
9149
+ }
9150
+ const code = error.code;
9151
+ if (typeof code === "string") {
9152
+ return code;
9153
+ }
9154
+ const first = (error.message || error.name).split(".")[0] ?? "";
9155
+ return first.length > 56 ? `${first.slice(0, 55)}…` : first;
9156
+ }
9157
+ function reason(site, error) {
9158
+ return `${site.name}: ${describe(error)}`;
9159
+ }
9160
+ function incomplete(input) {
9161
+ const at2 = input.lastIndexOf("\x1B");
9162
+ return at2 !== -1 && /^\[?[0-9;]*$/.test(input.slice(at2 + 1));
9163
+ }
9164
+ function readCache(site, name) {
9165
+ try {
9166
+ return JSON.parse(readFileSync6(join7(cacheDir(site), name), "utf8"));
9167
+ } catch {
9168
+ return {};
9169
+ }
9170
+ }
9171
+ function writeCache(site, name, data) {
9172
+ mkdirSync5(cacheDir(site), { recursive: true });
9173
+ writeFileSync5(join7(cacheDir(site), name), `${JSON.stringify(data)}
9174
+ `);
9175
+ }
9176
+
9177
+ class Finder {
8208
9178
  view;
8209
9179
  tone;
8210
9180
  session = new AbortController;
@@ -8213,19 +9183,28 @@ class Finder {
8213
9183
  rows = process.stdout.rows || 24;
8214
9184
  gen = 0;
8215
9185
  pumping = 0;
8216
- sources = [];
8217
- queue = [];
8218
- seen = new Set;
9186
+ boards = new Map;
9187
+ board;
8219
9188
  posts = new Map;
9189
+ thumbPath = new Map;
8220
9190
  siteIndex = 0;
9191
+ values = new Map(SETTINGS.map((setting) => [setting.name, initial(setting, process.env[setting.name])]));
9192
+ direction = 1;
9193
+ prefetching = 0;
9194
+ inflight = new Map;
8221
9195
  owners = new Map;
9196
+ probed = new Map;
9197
+ unsaved = new Set;
8222
9198
  thumbQueue = [];
8223
9199
  thumbing = 0;
9200
+ clarity = new Map;
9201
+ renders = new Renderer;
8224
9202
  sent = new Map;
8225
9203
  placed = new Map;
8226
9204
  current;
8227
9205
  fetch;
8228
9206
  settle;
9207
+ partial;
8229
9208
  input = "";
8230
9209
  probeWait;
8231
9210
  done;
@@ -8245,17 +9224,32 @@ class Finder {
8245
9224
  site: this.site.name,
8246
9225
  siteAnsi: this.site.ansi,
8247
9226
  nextSite: this.nextSite.name,
8248
- preset: "cutouts",
9227
+ preset: this.setting("TTHEME_FIND_POSTS") === "all" ? "all" : "cutouts",
9228
+ order: this.setting("TTHEME_FIND_ORDER") === "score" ? "score" : "newest",
9229
+ rating: ratingSet(this.setting("TTHEME_FIND_RATING")),
9230
+ block: blockSet(this.setting("TTHEME_FIND_BLOCK")),
9231
+ sets: this.setting("TTHEME_FIND_SETS") === "show" ? "show" : "fold",
9232
+ unblocked: process.env.TTHEME_FIND_PROXY !== undefined,
9233
+ settings: SETTINGS.map((setting) => ({
9234
+ label: setting.label,
9235
+ choices: [...setting.choices],
9236
+ value: this.setting(setting.name),
9237
+ multi: setting.multi && { none: setting.multi.none },
9238
+ cursor: 0
9239
+ })),
8249
9240
  colors: { cursor: entry.cursor, selection: entry.selection, ansi: entry.ansi },
8250
9241
  tiles: [],
8251
9242
  checked: 0,
8252
9243
  total: 0,
8253
- searching: true,
9244
+ searching: tag !== "",
8254
9245
  focus: 0,
8255
9246
  top: 0,
8256
9247
  mode: "grid",
8257
- help: false
9248
+ help: false,
9249
+ editing: tag === "" ? "" : undefined
8258
9250
  };
9251
+ this.board = blank(this.boardKey, tag !== "");
9252
+ this.boards.set(this.board.key, this.board);
8259
9253
  }
8260
9254
  get signal() {
8261
9255
  return this.session.signal;
@@ -8266,6 +9260,52 @@ class Finder {
8266
9260
  get nextSite() {
8267
9261
  return SITES[(this.siteIndex + 1) % SITES.length];
8268
9262
  }
9263
+ setting(name) {
9264
+ return this.values.get(name);
9265
+ }
9266
+ get boardKey() {
9267
+ return `${this.site.key}|${this.view.preset}|${this.view.order}`;
9268
+ }
9269
+ mark(site, id) {
9270
+ return `${site.key}:${id}`;
9271
+ }
9272
+ origPath(site, id, ext) {
9273
+ return join7(cacheDir(site), "orig", `${id}.${ext}`);
9274
+ }
9275
+ tileOf(site, post2, variants) {
9276
+ const version2 = rendition(post2) ?? post2;
9277
+ return {
9278
+ id: post2.id,
9279
+ width: version2.width,
9280
+ height: version2.height,
9281
+ reduced: version2 !== post2,
9282
+ owner: mirrored(post2.owner) ? "" : post2.owner,
9283
+ artist: post2.artist,
9284
+ score: post2.score,
9285
+ variants,
9286
+ origin: originHost(post2.source),
9287
+ mates: this.owners.get(site.key)?.get(post2.owner) ?? [],
9288
+ thumb: this.thumbPath.get(this.mark(site, post2.id))
9289
+ };
9290
+ }
9291
+ show() {
9292
+ const site = this.site;
9293
+ const view = this.view;
9294
+ const board = this.board;
9295
+ view.tiles = board.groups.flatMap((group) => group.open ? group.posts.map((post2) => this.tileOf(site, post2, 0)) : [this.tileOf(site, group.posts[0], group.posts.length)]);
9296
+ view.checked = board.checked;
9297
+ view.total = board.total;
9298
+ view.searching = board.searching;
9299
+ view.note = board.note;
9300
+ view.focus = Math.max(0, Math.min(board.focus, view.tiles.length - 1));
9301
+ view.top = board.top;
9302
+ }
9303
+ syncSite() {
9304
+ const view = this.view;
9305
+ view.site = this.site.name;
9306
+ view.siteAnsi = this.site.ansi;
9307
+ view.nextSite = this.nextSite.name;
9308
+ }
8269
9309
  async run() {
8270
9310
  const { stdin, stdout } = process;
8271
9311
  stdin.setRawMode(true);
@@ -8276,10 +9316,13 @@ class Finder {
8276
9316
  const exit = new Promise((resolve2) => {
8277
9317
  this.done = resolve2;
8278
9318
  });
9319
+ const clock = setInterval(this.onClock, 250);
8279
9320
  const cell = await this.probe();
8280
9321
  if (cell) {
8281
9322
  this.cell = cell;
8282
- this.search();
9323
+ if (this.view.tag) {
9324
+ this.search();
9325
+ }
8283
9326
  } else {
8284
9327
  this.view.searching = false;
8285
9328
  this.view.error = "this terminal does not report its cell size — find needs kitty graphics";
@@ -8289,12 +9332,17 @@ class Finder {
8289
9332
  this.session.abort();
8290
9333
  this.fetch?.abort();
8291
9334
  clearTimeout(this.settle);
9335
+ clearTimeout(this.partial);
9336
+ clearInterval(clock);
8292
9337
  stdin.off("data", this.onData);
8293
9338
  stdout.off("resize", this.onResize);
8294
9339
  this.write("\x1B_Ga=d,d=A,q=2\x1B\\\x1B[H\x1B[J");
8295
9340
  stdin.setRawMode(false);
8296
9341
  stdin.pause();
8297
- rmSync4(this.scratch, { recursive: true, force: true });
9342
+ this.saveProbes();
9343
+ this.renders.close();
9344
+ rmSync5(this.scratch, { recursive: true, force: true });
9345
+ sweepCache();
8298
9346
  return code;
8299
9347
  }
8300
9348
  write(text) {
@@ -8328,18 +9376,34 @@ class Finder {
8328
9376
  this.input = this.input.slice(0, at2) + this.input.slice(at2 + 1 + m[0].length);
8329
9377
  this.probeWait({ h: Number(m[1]), w: Number(m[2]) });
8330
9378
  }
9379
+ clearTimeout(this.partial);
9380
+ if (incomplete(this.input)) {
9381
+ this.partial = setTimeout(this.flushKeys, ESCAPE);
9382
+ return;
9383
+ }
9384
+ this.flushKeys();
9385
+ };
9386
+ flushKeys = () => {
8331
9387
  const keys = decodeKeys(this.input);
8332
9388
  this.input = "";
8333
9389
  for (const key of keys) {
8334
9390
  this.key(key);
8335
9391
  }
8336
9392
  };
9393
+ onClock = () => {
9394
+ const left = Math.ceil((pausedUntil(this.site) - Date.now()) / 1000);
9395
+ const waiting = left > 0 ? left : undefined;
9396
+ if (waiting !== this.view.waiting) {
9397
+ this.view.waiting = waiting;
9398
+ this.draw();
9399
+ }
9400
+ };
8337
9401
  onResize = () => {
8338
9402
  this.cols = process.stdout.columns || this.cols;
8339
9403
  this.rows = process.stdout.rows || this.rows;
8340
9404
  this.scroll();
8341
9405
  if (this.view.mode === "try" && this.current) {
8342
- this.present();
9406
+ this.reveal();
8343
9407
  }
8344
9408
  this.pump();
8345
9409
  this.draw();
@@ -8353,6 +9417,14 @@ class Finder {
8353
9417
  if (view.installing !== undefined) {
8354
9418
  return;
8355
9419
  }
9420
+ if (view.editing !== undefined) {
9421
+ this.editKey(key);
9422
+ return;
9423
+ }
9424
+ if (view.panel !== undefined) {
9425
+ this.panelKey(key);
9426
+ return;
9427
+ }
8356
9428
  if (view.help) {
8357
9429
  if (key === "?" || key === "esc") {
8358
9430
  view.help = false;
@@ -8401,29 +9473,265 @@ class Finder {
8401
9473
  this.select();
8402
9474
  return;
8403
9475
  }
8404
- if (key === "tab") {
9476
+ if (key === "c") {
8405
9477
  view.preset = view.preset === "cutouts" ? "all" : "cutouts";
8406
- this.search();
9478
+ this.turn();
8407
9479
  return;
8408
9480
  }
8409
- if (key === "p") {
9481
+ if (key === "tab") {
8410
9482
  this.siteIndex = (this.siteIndex + 1) % SITES.length;
8411
- this.current = undefined;
8412
- view.shown = undefined;
8413
- view.site = this.site.name;
8414
- view.siteAnsi = this.site.ansi;
8415
- view.nextSite = this.nextSite.name;
8416
- this.search();
9483
+ this.syncSite();
9484
+ this.turn();
9485
+ return;
9486
+ }
9487
+ if (key === "/") {
9488
+ view.editing = "";
9489
+ this.draw();
9490
+ return;
9491
+ }
9492
+ if (key === " ") {
9493
+ this.unfold();
9494
+ return;
9495
+ }
9496
+ if (key === "o") {
9497
+ this.openPage();
9498
+ return;
9499
+ }
9500
+ if (key === "s") {
9501
+ view.panel = 0;
9502
+ this.draw();
8417
9503
  return;
8418
9504
  }
8419
9505
  if (key === "esc") {
8420
9506
  this.finish(2);
8421
9507
  }
8422
9508
  }
9509
+ panelKey(key) {
9510
+ const view = this.view;
9511
+ const at2 = view.panel ?? 0;
9512
+ const row = view.settings[at2];
9513
+ if (key === "up" || key === "down") {
9514
+ view.panel = Math.max(0, Math.min(view.settings.length - 1, at2 + (key === "up" ? -1 : 1)));
9515
+ this.draw();
9516
+ return;
9517
+ }
9518
+ if ((key === "left" || key === "right") && row) {
9519
+ const step = key === "left" ? -1 : 1;
9520
+ if (row.multi) {
9521
+ row.cursor = (row.cursor + step + row.choices.length) % row.choices.length;
9522
+ } else {
9523
+ const index = row.choices.indexOf(row.value);
9524
+ row.value = row.choices[(index + step + row.choices.length) % row.choices.length];
9525
+ }
9526
+ this.draw();
9527
+ return;
9528
+ }
9529
+ if (key === " " && row?.multi) {
9530
+ const on = row.value.split(" ");
9531
+ const next = row.choices.filter((choice, k) => k === row.cursor ? !on.includes(choice) : on.includes(choice));
9532
+ const empty = row.multi.none;
9533
+ if (next.length > 0 || empty !== undefined) {
9534
+ row.value = next.length > 0 ? next.join(" ") : empty;
9535
+ this.draw();
9536
+ }
9537
+ return;
9538
+ }
9539
+ if (key === "esc") {
9540
+ view.settings.forEach((setting, i) => {
9541
+ setting.value = this.setting(SETTINGS[i]?.name ?? "");
9542
+ });
9543
+ view.panel = undefined;
9544
+ this.draw();
9545
+ return;
9546
+ }
9547
+ if (key === "enter" || key === "alt-c") {
9548
+ view.panel = undefined;
9549
+ this.adopt();
9550
+ }
9551
+ }
9552
+ adopt() {
9553
+ const view = this.view;
9554
+ const before = SETTINGS.map((setting) => this.setting(setting.name));
9555
+ view.settings.forEach((row, i) => {
9556
+ const setting = SETTINGS[i];
9557
+ if (setting) {
9558
+ this.values.set(setting.name, row.value);
9559
+ }
9560
+ });
9561
+ const changed = SETTINGS.filter((setting, i) => this.setting(setting.name) !== before[i]);
9562
+ if (changed.length === 0) {
9563
+ this.draw();
9564
+ return;
9565
+ }
9566
+ this.save(changed);
9567
+ view.rating = ratingSet(this.setting("TTHEME_FIND_RATING"));
9568
+ view.block = blockSet(this.setting("TTHEME_FIND_BLOCK"));
9569
+ view.sets = this.setting("TTHEME_FIND_SETS") === "show" ? "show" : "fold";
9570
+ view.preset = this.setting("TTHEME_FIND_POSTS") === "all" ? "all" : "cutouts";
9571
+ view.order = this.setting("TTHEME_FIND_ORDER") === "score" ? "score" : "newest";
9572
+ this.boards.clear();
9573
+ this.current = undefined;
9574
+ view.shown = undefined;
9575
+ view.mode = "grid";
9576
+ this.search();
9577
+ }
9578
+ save(changed) {
9579
+ const path2 = join7(this.home, "ttheme", "config.zsh");
9580
+ try {
9581
+ const before = existsSync3(path2) ? readFileSync6(path2, "utf8") : "";
9582
+ const after = changed.reduce((text, setting) => withSetting(text, setting.name, this.setting(setting.name)), before);
9583
+ writeFileSync5(path2, after);
9584
+ } catch (error) {
9585
+ this.view.error = error instanceof Error ? error.message : String(error);
9586
+ }
9587
+ }
9588
+ editKey(key) {
9589
+ const view = this.view;
9590
+ const text = view.editing ?? "";
9591
+ if (key === "esc") {
9592
+ view.editing = undefined;
9593
+ if (view.tag === "") {
9594
+ this.finish(2);
9595
+ return;
9596
+ }
9597
+ this.draw();
9598
+ return;
9599
+ }
9600
+ if (key === "enter") {
9601
+ view.editing = undefined;
9602
+ if (text.trim()) {
9603
+ this.commit(text.trim());
9604
+ return;
9605
+ }
9606
+ if (view.tag === "") {
9607
+ this.finish(2);
9608
+ return;
9609
+ }
9610
+ this.draw();
9611
+ return;
9612
+ }
9613
+ if (key === "backspace") {
9614
+ view.editing = text.slice(0, -1);
9615
+ this.draw();
9616
+ return;
9617
+ }
9618
+ if (key.length === 1 && key >= " ") {
9619
+ view.editing = text + key;
9620
+ this.draw();
9621
+ }
9622
+ }
9623
+ async commit(text) {
9624
+ const ref = postRef(text, this.site);
9625
+ if (ref) {
9626
+ await this.jump(ref.site, ref.id);
9627
+ return;
9628
+ }
9629
+ this.view.tag = text;
9630
+ this.boards.clear();
9631
+ await this.search();
9632
+ }
9633
+ async jump(site, id) {
9634
+ const gen = ++this.gen;
9635
+ this.siteIndex = SITES.indexOf(site);
9636
+ this.syncSite();
9637
+ this.current = undefined;
9638
+ this.view.shown = undefined;
9639
+ this.view.error = undefined;
9640
+ const board = blank(`${site.key}|post:${id}`, true);
9641
+ this.board = board;
9642
+ this.boards.set(board.key, board);
9643
+ this.show();
9644
+ this.draw();
9645
+ try {
9646
+ const post2 = await fetchPost(site, id, this.signal);
9647
+ if (gen !== this.gen) {
9648
+ return;
9649
+ }
9650
+ board.searching = false;
9651
+ if (!post2) {
9652
+ board.error = `${site.name} has no post ${id}`;
9653
+ this.view.error = board.error;
9654
+ } else {
9655
+ this.posts.set(this.mark(site, post2.id), post2);
9656
+ board.groups = [{ posts: [post2], open: false }];
9657
+ board.checked = 1;
9658
+ board.total = 1;
9659
+ this.thumbQueue.push({ site, post: post2 });
9660
+ this.thumbs();
9661
+ this.view.mode = "try";
9662
+ }
9663
+ this.show();
9664
+ if (post2) {
9665
+ this.select();
9666
+ }
9667
+ this.draw();
9668
+ } catch (error) {
9669
+ this.fail(gen, error);
9670
+ }
9671
+ }
9672
+ async turn() {
9673
+ this.current = undefined;
9674
+ this.view.shown = undefined;
9675
+ const known = this.boards.get(this.boardKey);
9676
+ if (!known) {
9677
+ await this.search();
9678
+ return;
9679
+ }
9680
+ this.gen++;
9681
+ this.board = known;
9682
+ this.view.error = known.error;
9683
+ this.show();
9684
+ this.draw();
9685
+ await this.pump();
9686
+ }
9687
+ unfold() {
9688
+ const view = this.view;
9689
+ let index = 0;
9690
+ for (const group of this.board.groups) {
9691
+ const size = group.open ? group.posts.length : 1;
9692
+ if (view.focus < index + size) {
9693
+ if (group.posts.length < 2) {
9694
+ return;
9695
+ }
9696
+ group.open = !group.open;
9697
+ this.board.focus = index;
9698
+ for (const post2 of group.posts) {
9699
+ if (!this.thumbPath.has(this.mark(this.site, post2.id))) {
9700
+ this.thumbQueue.push({ site: this.site, post: post2 });
9701
+ }
9702
+ }
9703
+ this.thumbs();
9704
+ this.show();
9705
+ this.scroll();
9706
+ this.draw();
9707
+ return;
9708
+ }
9709
+ index += size;
9710
+ }
9711
+ }
9712
+ openPage() {
9713
+ const tile = this.view.tiles[this.view.focus];
9714
+ if (!tile) {
9715
+ return;
9716
+ }
9717
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
9718
+ try {
9719
+ spawn2(opener, [this.site.pageUrl(tile.id)], { stdio: "ignore", detached: true }).unref();
9720
+ } catch {}
9721
+ }
8423
9722
  tryKey(key) {
8424
9723
  const view = this.view;
8425
9724
  if (key === "left" || key === "right") {
8426
- this.focus(view.focus + (key === "left" ? -1 : 1));
9725
+ this.direction = key === "left" ? -1 : 1;
9726
+ this.focus(view.focus + this.direction);
9727
+ return;
9728
+ }
9729
+ if (key === "o") {
9730
+ this.openPage();
9731
+ return;
9732
+ }
9733
+ if (key === "x") {
9734
+ this.swap();
8427
9735
  return;
8428
9736
  }
8429
9737
  if (key === "enter") {
@@ -8449,6 +9757,7 @@ class Finder {
8449
9757
  return;
8450
9758
  }
8451
9759
  view.focus = next;
9760
+ this.board.focus = next;
8452
9761
  this.scroll();
8453
9762
  this.pump();
8454
9763
  if (view.mode === "try") {
@@ -8466,135 +9775,217 @@ class Finder {
8466
9775
  if (row > view.top + rowsVis - 1) {
8467
9776
  view.top = row - rowsVis + 1;
8468
9777
  }
9778
+ this.board.top = view.top;
9779
+ }
9780
+ query(site) {
9781
+ const view = this.view;
9782
+ const wanted = [
9783
+ view.tag,
9784
+ site.rate(this.view.rating),
9785
+ view.preset === "cutouts" ? site.cutouts : "",
9786
+ view.order === "score" ? site.best : ""
9787
+ ].filter(Boolean);
9788
+ const kept = [];
9789
+ const dropped = [];
9790
+ for (const part of wanted) {
9791
+ if (tagsOf([...kept, part].join(" ")) <= site.tagBudget) {
9792
+ kept.push(part);
9793
+ } else {
9794
+ dropped.push(part);
9795
+ }
9796
+ }
9797
+ this.board.note = dropped.length > 0 ? `${site.name} takes ${site.tagBudget} tags — ${dropped.join(" ")} left out` : undefined;
9798
+ return kept.join(" ");
8469
9799
  }
8470
9800
  async search() {
8471
9801
  const gen = ++this.gen;
8472
- const view = this.view;
8473
- Object.assign(view, { tiles: [], checked: 0, total: 0, searching: true, focus: 0, top: 0, error: undefined });
8474
- this.seen.clear();
8475
- this.queue = [];
8476
- this.sources = [];
8477
- this.thumbQueue = [];
8478
- this.draw();
8479
9802
  const site = this.site;
8480
- const tags = view.preset === "cutouts" ? `${view.tag} ${site.cutouts}` : view.tag;
9803
+ const board = blank(this.boardKey, true);
9804
+ this.board = board;
9805
+ this.boards.set(board.key, board);
9806
+ this.view.error = undefined;
9807
+ this.show();
9808
+ this.draw();
9809
+ const tags = this.query(site);
9810
+ this.tally(gen, site, board, tags);
8481
9811
  try {
8482
- const [total, owners] = await Promise.all([fetchCount(site, tags, this.signal), this.mateOwners(site)]);
9812
+ const owners = await this.mateOwners(site);
8483
9813
  if (gen !== this.gen) {
8484
9814
  return;
8485
9815
  }
8486
- view.total = total;
8487
- this.sources = [
8488
- ...[...owners.keys()].map((owner) => ({ tags: `${tags} user:${owner}`, page: 0, done: false })),
9816
+ const room = tagsOf(tags) + 1 <= site.tagBudget;
9817
+ board.sources = [
9818
+ ...room ? [...owners.keys()].map((owner) => ({ tags: `${tags} user:${owner}`, page: 0, done: false })) : [],
8489
9819
  { tags, page: 0, done: false }
8490
9820
  ];
9821
+ this.show();
8491
9822
  this.draw();
8492
9823
  await this.pump();
8493
9824
  } catch (error) {
8494
9825
  this.fail(gen, error);
8495
9826
  }
8496
9827
  }
9828
+ async tally(gen, site, board, tags) {
9829
+ try {
9830
+ const total = await fetchCount(site, tags, this.signal);
9831
+ if (gen === this.gen) {
9832
+ board.total = total;
9833
+ this.show();
9834
+ this.draw();
9835
+ }
9836
+ } catch {}
9837
+ }
8497
9838
  fail(gen, error) {
8498
9839
  if (gen !== this.gen || this.signal.aborted) {
8499
9840
  return;
8500
9841
  }
8501
- this.view.error = reason(this.site, error);
8502
- this.view.searching = false;
9842
+ this.board.error = reason(this.site, error);
9843
+ this.board.searching = false;
9844
+ this.view.error = this.board.error;
9845
+ this.show();
8503
9846
  this.draw();
8504
9847
  }
8505
9848
  wants() {
8506
- const view = this.view;
8507
- if (view.checked < Math.min(view.total, PAGE)) {
8508
- return true;
8509
- }
8510
9849
  const { perRow, rowsVis } = gridShape(this.cols, this.rows);
8511
- return view.tiles.length < (view.top + rowsVis + LOOKAHEAD) * perRow;
9850
+ return this.view.tiles.length < (this.board.top + rowsVis + LOOKAHEAD) * perRow;
9851
+ }
9852
+ async nextPage(site, board, gen) {
9853
+ const source = board.sources.find((s) => !s.done);
9854
+ if (!source) {
9855
+ return;
9856
+ }
9857
+ const posts = await fetchPosts(site, source.tags, source.page, this.signal);
9858
+ if (gen !== this.gen) {
9859
+ return;
9860
+ }
9861
+ source.page++;
9862
+ source.done = posts.length < PAGE;
9863
+ for (const post2 of posts) {
9864
+ if (!board.seen.has(post2.id)) {
9865
+ board.seen.add(post2.id);
9866
+ board.queue.push(post2);
9867
+ }
9868
+ }
8512
9869
  }
8513
9870
  async pump() {
8514
9871
  const gen = this.gen;
8515
- if (this.pumping === gen || this.sources.length === 0) {
9872
+ const board = this.board;
9873
+ if (this.pumping === gen || board.sources.length === 0) {
8516
9874
  return;
8517
9875
  }
8518
9876
  this.pumping = gen;
8519
- const view = this.view;
9877
+ const site = this.site;
9878
+ const flight = [];
9879
+ let page;
8520
9880
  try {
8521
- view.searching = true;
8522
- while (gen === this.gen && this.wants()) {
8523
- if (this.queue.length === 0) {
8524
- const source = this.sources.find((s) => !s.done);
8525
- if (!source) {
8526
- break;
8527
- }
8528
- const posts = await fetchPosts(this.site, source.tags, source.page, this.signal);
8529
- if (gen !== this.gen) {
8530
- return;
8531
- }
8532
- source.page++;
8533
- source.done = posts.length < PAGE;
8534
- for (const post2 of posts) {
8535
- if (!this.seen.has(post2.id)) {
8536
- this.seen.add(post2.id);
8537
- this.queue.push(post2);
9881
+ board.searching = true;
9882
+ while (gen === this.gen) {
9883
+ while (flight.length < PROBE && board.queue.length > 0 && this.wants()) {
9884
+ const post2 = board.queue.shift();
9885
+ flight.push({ post: post2, passed: this.passes(site, post2) });
9886
+ }
9887
+ if (!page && board.queue.length < PROBE && board.sources.some((s) => !s.done) && this.wants()) {
9888
+ const job = this.nextPage(site, board, gen).then(() => {
9889
+ if (page === job) {
9890
+ page = undefined;
8538
9891
  }
9892
+ });
9893
+ job.catch(() => {});
9894
+ page = job;
9895
+ }
9896
+ const head = flight.shift();
9897
+ if (!head) {
9898
+ if (!page) {
9899
+ break;
8539
9900
  }
9901
+ await page;
8540
9902
  continue;
8541
9903
  }
8542
- const batch = this.queue.splice(0, WORKERS);
8543
- const passed = await Promise.all(batch.map((post2) => this.passes(post2)));
9904
+ const passed = await head.passed;
8544
9905
  if (gen !== this.gen) {
8545
9906
  return;
8546
9907
  }
8547
- batch.forEach((post2, i) => {
8548
- view.checked++;
8549
- if (passed[i]) {
8550
- this.admit(post2);
8551
- }
8552
- });
9908
+ board.checked++;
9909
+ if (passed) {
9910
+ this.admit(site, head.post);
9911
+ }
9912
+ this.show();
8553
9913
  this.draw();
8554
9914
  }
8555
9915
  if (gen === this.gen) {
8556
- view.searching = false;
9916
+ board.searching = false;
9917
+ this.show();
8557
9918
  this.draw();
8558
9919
  }
8559
9920
  } catch (error) {
8560
9921
  this.fail(gen, error);
8561
9922
  } finally {
9923
+ this.saveProbes();
8562
9924
  if (this.pumping === gen) {
8563
9925
  this.pumping = 0;
8564
9926
  }
8565
9927
  }
8566
9928
  }
8567
- async passes(post2) {
8568
- if (!safe(post2)) {
9929
+ async passes(site, post2) {
9930
+ const version2 = rendition(post2);
9931
+ if (!rated(site, post2, this.view.rating) || exposed(post2, this.view.block).length > 0 || !version2) {
8569
9932
  return false;
8570
9933
  }
8571
9934
  if (this.view.preset === "all") {
8572
- return ["png", "jpg", "jpeg"].includes(post2.ext);
9935
+ return ["png", "jpg", "jpeg"].includes(version2.ext);
8573
9936
  }
8574
9937
  if (post2.ext !== "png") {
8575
9938
  return false;
8576
9939
  }
8577
- if (this.site.vouched) {
8578
- return true;
9940
+ return site.vouched || this.transparent(site, post2);
9941
+ }
9942
+ probes(site) {
9943
+ let known = this.probed.get(site.key);
9944
+ if (!known) {
9945
+ known = readCache(site, "probes.json");
9946
+ this.probed.set(site.key, known);
9947
+ }
9948
+ return known;
9949
+ }
9950
+ async transparent(site, post2) {
9951
+ const known = this.probes(site);
9952
+ const cached = known[post2.id];
9953
+ if (cached !== undefined) {
9954
+ return cached;
8579
9955
  }
8580
9956
  try {
8581
- return (await headOf(this.site, post2.file, this.signal))?.alpha === true;
9957
+ const alpha = (await headOf(site, post2.file, this.signal))?.alpha === true;
9958
+ known[post2.id] = alpha;
9959
+ this.unsaved.add(site.key);
9960
+ return alpha;
8582
9961
  } catch {
8583
9962
  return false;
8584
9963
  }
8585
9964
  }
8586
- admit(post2) {
8587
- const tile = {
8588
- id: post2.id,
8589
- width: post2.width,
8590
- height: post2.height,
8591
- owner: post2.owner,
8592
- origin: originHost(post2.source),
8593
- mates: this.owners.get(this.site.key)?.get(post2.owner) ?? []
8594
- };
8595
- this.posts.set(post2.id, post2);
8596
- this.view.tiles.push(tile);
8597
- this.thumbQueue.push(tile);
9965
+ saveProbes() {
9966
+ for (const key of this.unsaved) {
9967
+ const site = SITES.find((s) => s.key === key);
9968
+ if (site) {
9969
+ writeCache(site, "probes.json", this.probes(site));
9970
+ }
9971
+ }
9972
+ this.unsaved.clear();
9973
+ }
9974
+ admit(site, post2) {
9975
+ this.posts.set(this.mark(site, post2.id), post2);
9976
+ const last = this.view.sets === "fold" ? this.board.groups.at(-1) : undefined;
9977
+ const head = last?.posts[0];
9978
+ const credit = post2.owner || post2.artist;
9979
+ if (last && head && credit && (head.owner || head.artist) === credit && head.width === post2.width && head.height === post2.height) {
9980
+ last.posts.push(post2);
9981
+ if (last.open) {
9982
+ this.thumbQueue.push({ site, post: post2 });
9983
+ this.thumbs();
9984
+ }
9985
+ return;
9986
+ }
9987
+ this.board.groups.push({ posts: [post2], open: false });
9988
+ this.thumbQueue.push({ site, post: post2 });
8598
9989
  this.thumbs();
8599
9990
  }
8600
9991
  async mateOwners(site) {
@@ -8603,70 +9994,76 @@ class Finder {
8603
9994
  return cached;
8604
9995
  }
8605
9996
  const found = origins(this.home);
8606
- const cachePath = join7(cacheDir(site), "owners.json");
8607
- let known = {};
8608
- try {
8609
- known = JSON.parse(readFileSync4(cachePath, "utf8"));
8610
- } catch {}
8611
- const byPalette = new Map;
8612
- for (const sibling of this.catalog.palettes) {
9997
+ const known = readCache(site, "owners.json");
9998
+ const siblings = this.catalog.palettes.flatMap((sibling) => {
8613
9999
  const origin = found.get(sibling.name);
8614
- if (sibling.group !== this.entry.group || sibling.name === this.entry.name || origin?.site !== site.key) {
8615
- continue;
10000
+ return sibling.group === this.entry.group && sibling.name !== this.entry.name && origin?.site === site.key ? [{ name: sibling.name, id: origin.id }] : [];
10001
+ });
10002
+ const asked = await Promise.all(siblings.map(async (sibling) => {
10003
+ const owner = known[sibling.id];
10004
+ if (owner !== undefined) {
10005
+ return { ...sibling, owner };
8616
10006
  }
8617
- let owner = known[origin.id];
8618
- if (owner === undefined) {
8619
- try {
8620
- owner = (await fetchPost(site, origin.id, this.signal))?.owner ?? "";
8621
- known[origin.id] = owner;
8622
- } catch {
8623
- continue;
8624
- }
10007
+ try {
10008
+ return { ...sibling, owner: (await fetchPost(site, sibling.id, this.signal))?.owner ?? "" };
10009
+ } catch {
10010
+ return;
10011
+ }
10012
+ }));
10013
+ const byPalette = new Map;
10014
+ for (const sibling of asked) {
10015
+ if (sibling) {
10016
+ known[sibling.id] = sibling.owner;
10017
+ byPalette.set(sibling.name, sibling.owner);
8625
10018
  }
8626
- byPalette.set(sibling.name, owner);
8627
10019
  }
8628
- mkdirSync4(dirname3(cachePath), { recursive: true });
8629
- writeFileSync4(cachePath, `${JSON.stringify(known)}
8630
- `);
10020
+ writeCache(site, "owners.json", known);
8631
10021
  const owners = mates(byPalette);
8632
10022
  this.owners.set(site.key, owners);
8633
10023
  return owners;
8634
10024
  }
8635
- async cached(site, path2, url) {
10025
+ async cached(site, path2, url, progress2) {
8636
10026
  if (existsSync3(path2)) {
8637
- return new Uint8Array(readFileSync4(path2));
10027
+ return statSync3(path2).size;
10028
+ }
10029
+ const running = this.inflight.get(path2);
10030
+ if (running) {
10031
+ return running;
10032
+ }
10033
+ const job = (async () => {
10034
+ const bytes = await fetchBytes(site, url, this.signal, progress2);
10035
+ mkdirSync5(dirname4(path2), { recursive: true });
10036
+ writeFileSync5(path2, bytes);
10037
+ return bytes.length;
10038
+ })();
10039
+ this.inflight.set(path2, job);
10040
+ try {
10041
+ return await job;
10042
+ } finally {
10043
+ this.inflight.delete(path2);
8638
10044
  }
8639
- const bytes = await fetchBytes(site, url, this.signal);
8640
- mkdirSync4(dirname3(path2), { recursive: true });
8641
- writeFileSync4(path2, bytes);
8642
- return bytes;
8643
10045
  }
8644
10046
  thumbs() {
8645
- while (this.thumbing < WORKERS && this.thumbQueue.length > 0) {
8646
- const tile = this.thumbQueue.shift();
10047
+ while (this.thumbing < THUMB && this.thumbQueue.length > 0) {
10048
+ const { site, post: post2 } = this.thumbQueue.shift();
8647
10049
  this.thumbing++;
8648
- this.thumb(tile).catch(() => {}).finally(() => {
10050
+ this.thumb(site, post2).catch(() => {}).finally(() => {
8649
10051
  this.thumbing--;
8650
10052
  this.thumbs();
8651
10053
  });
8652
10054
  }
8653
10055
  }
8654
- async thumb(tile) {
8655
- const post2 = this.posts.get(tile.id);
8656
- const site = this.site;
8657
- if (!post2) {
8658
- return;
8659
- }
10056
+ async thumb(site, post2) {
8660
10057
  const w = TILE.cols * this.cell.w;
8661
10058
  const h = TILE.rows * this.cell.h;
8662
- const path2 = join7(cacheDir(site), "tile", `${tile.id}-${w}x${h}.png`);
10059
+ const path2 = join7(cacheDir(site), "tile", `${post2.id}-${w}x${h}.png`);
8663
10060
  if (!existsSync3(path2)) {
8664
- const thumb = join7(cacheDir(site), "thumb", `${tile.id}.${extension(post2.preview)}`);
8665
- const bytes = await this.cached(site, thumb, post2.preview);
8666
- mkdirSync4(dirname3(path2), { recursive: true });
8667
- writeFileSync4(path2, encodePng(contain(decodeImage(bytes), w, h)));
10061
+ const thumb = join7(cacheDir(site), "thumb", `${post2.id}.${extension(post2.preview)}`);
10062
+ await this.cached(site, thumb, post2.preview);
10063
+ await this.renders.run({ job: "thumb", from: thumb, to: path2, width: w, height: h });
8668
10064
  }
8669
- tile.thumb = path2;
10065
+ this.thumbPath.set(this.mark(site, post2.id), path2);
10066
+ this.show();
8670
10067
  this.draw();
8671
10068
  }
8672
10069
  select() {
@@ -8679,8 +10076,7 @@ class Finder {
8679
10076
  return;
8680
10077
  }
8681
10078
  if (this.current?.id === tile.id) {
8682
- this.present();
8683
- this.draw();
10079
+ this.reveal();
8684
10080
  return;
8685
10081
  }
8686
10082
  this.settle = setTimeout(() => void this.load(tile), SETTLE);
@@ -8688,46 +10084,74 @@ class Finder {
8688
10084
  }
8689
10085
  async load(tile) {
8690
10086
  const view = this.view;
8691
- const post2 = this.posts.get(tile.id);
8692
- if (!post2) {
10087
+ const post2 = this.posts.get(this.mark(this.site, tile.id));
10088
+ const version2 = post2 && rendition(post2);
10089
+ if (!version2) {
8693
10090
  return;
8694
10091
  }
8695
10092
  const site = this.site;
8696
10093
  const control = new AbortController;
8697
10094
  this.fetch = control;
8698
- const signal = AbortSignal.any([this.signal, control.signal]);
8699
10095
  try {
8700
- const path2 = join7(cacheDir(site), "orig", `${tile.id}.${post2.ext}`);
8701
- let bytes;
8702
- if (existsSync3(path2)) {
8703
- bytes = new Uint8Array(readFileSync4(path2));
8704
- } else {
10096
+ const path2 = this.origPath(site, tile.id, version2.ext);
10097
+ if (!existsSync3(path2)) {
8705
10098
  view.fetching = { id: tile.id, got: 0, size: 0 };
8706
10099
  this.draw();
8707
- bytes = await fetchBytes(site, post2.file, signal, (got, size) => {
8708
- if (view.fetching?.id === tile.id) {
8709
- view.fetching = { id: tile.id, got, size };
8710
- this.draw();
8711
- }
8712
- });
8713
- mkdirSync4(dirname3(path2), { recursive: true });
8714
- writeFileSync4(path2, bytes);
8715
10100
  }
10101
+ const size = await this.cached(site, path2, version2.file, (got, total) => {
10102
+ if (view.fetching?.id === tile.id) {
10103
+ view.fetching = { id: tile.id, got, size: total };
10104
+ this.draw();
10105
+ }
10106
+ });
8716
10107
  if (control.signal.aborted) {
8717
10108
  return;
8718
10109
  }
8719
10110
  view.fetching = undefined;
10111
+ if (site !== this.site) {
10112
+ return;
10113
+ }
10114
+ const current = { site, id: tile.id, path: path2, ext: version2.ext, size, using: "plain", failed: false };
8720
10115
  view.preparing = tile.id;
8721
10116
  this.flush();
8722
- await tick();
8723
- const image = decodeImage(bytes);
8724
- if (site !== this.site) {
10117
+ const plain2 = await this.render(current, "plain");
10118
+ if (control.signal.aborted || site !== this.site) {
8725
10119
  return;
8726
10120
  }
8727
- this.current = { site, id: tile.id, image, bytes, ext: post2.ext, clear: transparency(image) };
10121
+ if (plain2.clear === 0 && canRemoveBackground() && this.setting("TTHEME_FIND_REMOVE_BG") !== "off") {
10122
+ view.preparing = undefined;
10123
+ view.cutting = tile.id;
10124
+ this.flush();
10125
+ current.cut = await this.cutOut(site, tile.id, path2, control.signal);
10126
+ if (control.signal.aborted || site !== this.site) {
10127
+ return;
10128
+ }
10129
+ view.cutting = undefined;
10130
+ if (current.cut) {
10131
+ view.preparing = tile.id;
10132
+ this.flush();
10133
+ const made = await this.render(current, "cut").catch(() => {
10134
+ return;
10135
+ });
10136
+ if (control.signal.aborted || site !== this.site) {
10137
+ return;
10138
+ }
10139
+ if (made && keepable(made.clear)) {
10140
+ current.using = "cut";
10141
+ } else {
10142
+ rmSync5(current.cut, { force: true });
10143
+ current.cut = undefined;
10144
+ current.failed = true;
10145
+ }
10146
+ } else {
10147
+ current.failed = true;
10148
+ }
10149
+ }
10150
+ this.current = current;
8728
10151
  if (view.tiles[view.focus]?.id === tile.id) {
8729
- this.present();
10152
+ await this.present();
8730
10153
  }
10154
+ this.preload();
8731
10155
  } catch (error) {
8732
10156
  if (!control.signal.aborted && !this.signal.aborted) {
8733
10157
  view.error = reason(site, error);
@@ -8739,23 +10163,110 @@ class Finder {
8739
10163
  if (view.preparing === tile.id) {
8740
10164
  view.preparing = undefined;
8741
10165
  }
10166
+ if (view.cutting === tile.id) {
10167
+ view.cutting = undefined;
10168
+ }
8742
10169
  this.draw();
8743
10170
  }
8744
10171
  }
8745
- present() {
10172
+ async cutOut(site, id, path2, signal) {
10173
+ const out = join7(cacheDir(site), "cut", `${id}.png`);
10174
+ if (existsSync3(out)) {
10175
+ return out;
10176
+ }
10177
+ try {
10178
+ mkdirSync5(dirname4(out), { recursive: true });
10179
+ await removeBackground(path2, out, signal);
10180
+ return out;
10181
+ } catch {
10182
+ rmSync5(out, { force: true });
10183
+ return;
10184
+ }
10185
+ }
10186
+ swap() {
8746
10187
  const current = this.current;
8747
- if (!current) {
10188
+ if (!current?.cut || this.view.shown?.id !== current.id) {
10189
+ return;
10190
+ }
10191
+ current.using = current.using === "cut" ? "plain" : "cut";
10192
+ this.reveal();
10193
+ }
10194
+ preload() {
10195
+ const view = this.view;
10196
+ if (view.mode !== "try") {
8748
10197
  return;
8749
10198
  }
10199
+ const site = this.site;
10200
+ for (const index of [view.focus + this.direction, view.focus - this.direction]) {
10201
+ if (this.prefetching >= PRELOAD) {
10202
+ return;
10203
+ }
10204
+ const tile = view.tiles[index];
10205
+ const post2 = tile && this.posts.get(this.mark(site, tile.id));
10206
+ const version2 = post2 && rendition(post2);
10207
+ if (!version2) {
10208
+ continue;
10209
+ }
10210
+ const path2 = this.origPath(site, post2.id, version2.ext);
10211
+ this.prefetching++;
10212
+ this.cached(site, path2, version2.file).then(() => this.render({ site, id: post2.id, path: path2, ext: version2.ext, size: 0, using: "plain", failed: false }, "plain")).catch(() => {}).finally(() => {
10213
+ this.prefetching--;
10214
+ });
10215
+ }
10216
+ }
10217
+ async render(current, using) {
8750
10218
  const W = this.cols * this.cell.w;
8751
10219
  const H = this.rows * this.cell.h;
8752
10220
  const width2 = Math.min(W, TRY_WIDTH);
8753
10221
  const height = Math.max(1, Math.round(H * width2 / W));
8754
- const path2 = join7(this.scratch, `${current.id}-${width2}x${height}.png`);
8755
- if (!existsSync3(path2)) {
8756
- writeFileSync4(path2, encodePng(tryOn(current.image, this.entry, this.tone, width2, height)));
10222
+ const path2 = join7(this.scratch, `${current.site.key}-${current.id}${using === "cut" ? "c" : ""}-${width2}x${height}.png`);
10223
+ const key = `${current.site.key}:${current.id}:${using}`;
10224
+ const known = this.clarity.get(key);
10225
+ if (known !== undefined && existsSync3(path2)) {
10226
+ return { clear: known, path: path2 };
10227
+ }
10228
+ const clear = await this.renders.run({
10229
+ job: "show",
10230
+ from: using === "cut" ? current.cut : current.path,
10231
+ to: path2,
10232
+ width: width2,
10233
+ height,
10234
+ colors: this.entry,
10235
+ tone: this.tone
10236
+ });
10237
+ this.clarity.set(key, clear);
10238
+ return { clear, path: path2 };
10239
+ }
10240
+ async present() {
10241
+ const current = this.current;
10242
+ if (!current) {
10243
+ return;
10244
+ }
10245
+ const using = current.using;
10246
+ const { clear, path: path2 } = await this.render(current, using);
10247
+ if (this.current !== current || current.using !== using) {
10248
+ return;
8757
10249
  }
8758
- this.view.shown = { id: current.id, clear: current.clear, bytes: current.bytes.length, path: path2 };
10250
+ this.view.shown = {
10251
+ id: current.id,
10252
+ clear,
10253
+ bytes: current.size,
10254
+ path: path2,
10255
+ cut: current.cut ? using === "cut" ? "on" : "off" : current.failed ? "failed" : "none"
10256
+ };
10257
+ }
10258
+ reveal() {
10259
+ const view = this.view;
10260
+ view.preparing = this.current?.id;
10261
+ this.flush();
10262
+ this.present().catch((error) => {
10263
+ view.error = error instanceof Error ? error.message : String(error);
10264
+ }).finally(() => {
10265
+ if (view.preparing === this.current?.id) {
10266
+ view.preparing = undefined;
10267
+ }
10268
+ this.draw();
10269
+ });
8759
10270
  }
8760
10271
  async install() {
8761
10272
  const view = this.view;
@@ -8767,15 +10278,24 @@ class Finder {
8767
10278
  view.installing = tile.id;
8768
10279
  view.error = undefined;
8769
10280
  this.flush();
8770
- await tick();
8771
10281
  try {
8772
- installBackdrop(this.home, this.entry, this.tone, current.image, {
8773
- site: current.site.key,
8774
- id: current.id,
8775
- ext: current.ext,
8776
- bytes: current.bytes,
8777
- from: `${current.site.name} ${current.id} ${current.site.pageUrl(current.id)}`
10282
+ await this.renders.run({
10283
+ job: "backdrop",
10284
+ from: current.using === "cut" ? current.cut : current.path,
10285
+ source: current.path,
10286
+ home: this.home,
10287
+ colors: this.entry,
10288
+ tone: this.tone,
10289
+ origin: {
10290
+ site: current.site.key,
10291
+ id: current.id,
10292
+ ext: current.ext,
10293
+ from: `${current.site.name} ${current.id} ${current.site.pageUrl(current.id)}`
10294
+ }
8778
10295
  });
10296
+ const known = readCache(current.site, "owners.json");
10297
+ known[current.id] = this.posts.get(this.mark(current.site, current.id))?.owner ?? "";
10298
+ writeCache(current.site, "owners.json", known);
8779
10299
  process.stderr.write(`background · ${this.entry.name} ← ${current.site.name} ${current.id}
8780
10300
  `);
8781
10301
  this.finish(0);
@@ -8825,17 +10345,64 @@ class Finder {
8825
10345
  this.write(`${out}\x1B[?2026l`);
8826
10346
  }
8827
10347
  }
10348
+ function routable() {
10349
+ if (process.versions.bun) {
10350
+ return true;
10351
+ }
10352
+ const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
10353
+ return major > 22 || major === 22 && minor >= 21;
10354
+ }
10355
+ async function relaunch() {
10356
+ if (!routable()) {
10357
+ process.stderr.write(`unblock needs node 22.21 or newer — this is ${process.versions.node}
10358
+ `);
10359
+ return 1;
10360
+ }
10361
+ const proxy = await tunnel();
10362
+ const child = spawn2(process.execPath, process.argv.slice(1), {
10363
+ stdio: "inherit",
10364
+ env: {
10365
+ ...process.env,
10366
+ NODE_USE_ENV_PROXY: "1",
10367
+ NODE_NO_WARNINGS: "1",
10368
+ HTTPS_PROXY: `http://127.0.0.1:${proxy.port}`,
10369
+ TTHEME_FIND_PROXY: String(proxy.port)
10370
+ }
10371
+ });
10372
+ const code = await new Promise((resolve2) => child.on("exit", (status2) => resolve2(status2 ?? 1)));
10373
+ proxy.close();
10374
+ return code;
10375
+ }
8828
10376
  async function runFind(name) {
10377
+ if (process.env.TTHEME_FIND_UNBLOCK === "1" && !process.env.TTHEME_FIND_PROXY) {
10378
+ return relaunch();
10379
+ }
8829
10380
  const home = configHome();
8830
10381
  const catalog = readCatalog(home);
8831
10382
  const entry = find2(catalog.palettes, name);
8832
- if (!entry.booru) {
8833
- throw new Error(`${name} has no booru tag to search for`);
8834
- }
8835
10383
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
8836
10384
  throw new Error("ttheme find needs a terminal");
8837
10385
  }
8838
- return new Finder(home, catalog, entry, entry.booru).run();
10386
+ return new Finder(home, catalog, entry, entry.booru ?? "").run();
10387
+ }
10388
+
10389
+ // src/images.ts
10390
+ function runImage(name, action) {
10391
+ const home = configHome();
10392
+ find2(readCatalog(home).palettes, name);
10393
+ if (action === "next" || action === "prev") {
10394
+ const { key, at: at2, of } = switchImage(home, name, action === "next" ? 1 : -1);
10395
+ process.stderr.write(`background · ${name} ${at2}/${of} ${key}
10396
+ `);
10397
+ return 0;
10398
+ }
10399
+ if (action === "drop") {
10400
+ const { key, left } = dropImage(home, name);
10401
+ process.stderr.write(`background · ${name} removed ${key ?? "the picture"} · ${left} left
10402
+ `);
10403
+ return 0;
10404
+ }
10405
+ throw new Error(`unknown image action ${action} — next, prev or drop`);
8839
10406
  }
8840
10407
 
8841
10408
  // src/init.ts
@@ -8843,14 +10410,14 @@ import {
8843
10410
  chmodSync,
8844
10411
  copyFileSync,
8845
10412
  existsSync as existsSync5,
8846
- mkdirSync as mkdirSync5,
8847
- readdirSync as readdirSync3,
8848
- readFileSync as readFileSync5,
8849
- rmSync as rmSync5,
8850
- writeFileSync as writeFileSync5
10413
+ mkdirSync as mkdirSync6,
10414
+ readdirSync as readdirSync4,
10415
+ readFileSync as readFileSync7,
10416
+ rmSync as rmSync6,
10417
+ writeFileSync as writeFileSync6
8851
10418
  } from "node:fs";
8852
10419
  import { homedir as homedir3 } from "node:os";
8853
- import { dirname as dirname4, join as join8 } from "node:path";
10420
+ import { dirname as dirname5, join as join8 } from "node:path";
8854
10421
 
8855
10422
  // node_modules/@clack/core/dist/index.mjs
8856
10423
  import { styleText } from "node:util";
@@ -10789,12 +12356,12 @@ async function runBrowse() {
10789
12356
 
10790
12357
  // src/init.ts
10791
12358
  function copyDir(copies, from, to) {
10792
- for (const f2 of readdirSync3(from)) {
12359
+ for (const f2 of readdirSync4(from)) {
10793
12360
  copies.push({ from: join8(from, f2), to: join8(to, f2) });
10794
12361
  }
10795
12362
  }
10796
12363
  function loadManifest(root2) {
10797
- return JSON.parse(readFileSync5(join8(root2, "dist", "manifest.json"), "utf8"));
12364
+ return JSON.parse(readFileSync7(join8(root2, "dist", "manifest.json"), "utf8"));
10798
12365
  }
10799
12366
  function planInit(opts, paths) {
10800
12367
  const dist = join8(paths.root, "dist");
@@ -10810,7 +12377,7 @@ function planInit(opts, paths) {
10810
12377
  const configPath = join8(home, "config.zsh");
10811
12378
  const settings2 = {
10812
12379
  file: configPath,
10813
- content: configFile(existsSync5(configPath) ? readFileSync5(configPath, "utf8") : "")
12380
+ content: configFile(existsSync5(configPath) ? readFileSync7(configPath, "utf8") : "")
10814
12381
  };
10815
12382
  const notes = [];
10816
12383
  if (opts.terminals.includes("ghostty")) {
@@ -10818,7 +12385,7 @@ function planInit(opts, paths) {
10818
12385
  }
10819
12386
  if (opts.terminals.includes("alacritty")) {
10820
12387
  const config = join8(paths.configHome, "alacritty", "alacritty.toml");
10821
- if (existsSync5(config) && !readFileSync5(config, "utf8").includes("# ttheme begin")) {
12388
+ if (existsSync5(config) && !readFileSync7(config, "utf8").includes("# ttheme begin")) {
10822
12389
  notes.push("alacritty.toml already exists — ttheme left it alone; add its themes/ import yourself");
10823
12390
  }
10824
12391
  }
@@ -10828,23 +12395,23 @@ function planInit(opts, paths) {
10828
12395
  }
10829
12396
  function applyInit(plan) {
10830
12397
  for (const c2 of plan.copies) {
10831
- mkdirSync5(dirname4(c2.to), { recursive: true });
10832
- rmSync5(c2.to, { force: true });
12398
+ mkdirSync6(dirname5(c2.to), { recursive: true });
12399
+ rmSync6(c2.to, { force: true });
10833
12400
  copyFileSync(c2.from, c2.to);
10834
12401
  if (c2.executable) {
10835
12402
  chmodSync(c2.to, 493);
10836
12403
  }
10837
12404
  }
10838
- mkdirSync5(dirname4(plan.settings.file), { recursive: true });
10839
- writeFileSync5(plan.settings.file, plan.settings.content);
10840
- const configHome2 = dirname4(dirname4(plan.settings.file));
12405
+ mkdirSync6(dirname5(plan.settings.file), { recursive: true });
12406
+ writeFileSync6(plan.settings.file, plan.settings.content);
12407
+ const configHome2 = dirname5(dirname5(plan.settings.file));
10841
12408
  writeCatalog(configHome2, plan.catalog);
10842
12409
  writeInstalled(configHome2, plan.installed);
10843
12410
  sync(configHome2, plan.catalog, plan.installed);
10844
12411
  for (const e of plan.edits) {
10845
- mkdirSync5(dirname4(e.file), { recursive: true });
10846
- const current = existsSync5(e.file) ? readFileSync5(e.file, "utf8") : "";
10847
- writeFileSync5(e.file, upsertBlock(current, e.block));
12412
+ mkdirSync6(dirname5(e.file), { recursive: true });
12413
+ const current = existsSync5(e.file) ? readFileSync7(e.file, "utf8") : "";
12414
+ writeFileSync6(e.file, upsertBlock(current, e.block));
10848
12415
  }
10849
12416
  }
10850
12417
  function accepted(value) {
@@ -10866,7 +12433,7 @@ function verify(plan) {
10866
12433
  const missing = [
10867
12434
  ...plan.copies.filter((c2) => !existsSync5(c2.to)).map((c2) => c2.to),
10868
12435
  ...existsSync5(plan.settings.file) ? [] : [plan.settings.file],
10869
- ...plan.edits.filter((e) => !readFileSync5(e.file, "utf8").includes("# ttheme begin")).map((e) => e.file)
12436
+ ...plan.edits.filter((e) => !readFileSync7(e.file, "utf8").includes("# ttheme begin")).map((e) => e.file)
10870
12437
  ];
10871
12438
  if (missing.length > 0) {
10872
12439
  throw new Error(`init left gaps:
@@ -10991,6 +12558,9 @@ function createProgram() {
10991
12558
  program2.command("find", { hidden: true }).argument("<palette>").description("pick a safebooru background for a palette — preview opens this on tab").action(async (name) => {
10992
12559
  process.exitCode = await runFind(name);
10993
12560
  });
12561
+ program2.command("image", { hidden: true }).argument("<palette>").argument("<action>", "next, prev or drop").description("switch a palette between its saved backgrounds, or remove the one shown — preview calls this").action((name, action) => {
12562
+ process.exitCode = runImage(name, action);
12563
+ });
10994
12564
  program2.command("update").description("refresh the catalog from the registry").action(() => runUpdate());
10995
12565
  return program2;
10996
12566
  }
@@ -11009,4 +12579,6 @@ ${error instanceof Error ? error.message : String(error)}`);
11009
12579
  }
11010
12580
 
11011
12581
  // src/bin.ts
11012
- process.exitCode = await runCli(process.argv);
12582
+ if (isMainThread2) {
12583
+ process.exitCode = await runCli(process.argv);
12584
+ }