@kecan0406/ttheme 0.1.1 → 0.1.3

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
@@ -6144,7 +6144,7 @@ var program = new Command;
6144
6144
  // package.json
6145
6145
  var package_default = {
6146
6146
  name: "@kecan0406/ttheme",
6147
- version: "0.1.1",
6147
+ version: "0.1.3",
6148
6148
  type: "module",
6149
6149
  bin: {
6150
6150
  ttheme: "bin/ttheme.js"
@@ -6804,6 +6804,42 @@ var CONFIG_SETTINGS = {
6804
6804
  TTHEME_SORT: {
6805
6805
  doc: "# series and palettes in ttheme and preview: abc sorts them by name, series keeps the order they were added (default abc)",
6806
6806
  default: "abc"
6807
+ },
6808
+ TTHEME_FIND_RATING: {
6809
+ doc: "# the ratings find lists, any of safe, questionable and explicit, each booru read in its own rating vocabulary (default safe)",
6810
+ default: "safe"
6811
+ },
6812
+ TTHEME_FIND_BLOCK: {
6813
+ doc: '# the posts find drops by tag: nudity, underwear, both, or none to keep them all (default "nudity underwear")',
6814
+ default: "nudity underwear"
6815
+ },
6816
+ TTHEME_FIND_POSTS: {
6817
+ doc: "# what find lists first: all is every post of the character, cutouts are the transparent ones (default all)",
6818
+ default: "all"
6819
+ },
6820
+ TTHEME_FIND_CUTOUTS: {
6821
+ 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)',
6822
+ default: ""
6823
+ },
6824
+ TTHEME_FIND_ORDER: {
6825
+ doc: "# the order find lists posts in: newest or score (default newest)",
6826
+ default: "newest"
6827
+ },
6828
+ TTHEME_FIND_SETS: {
6829
+ doc: "# runs of the same picture at the same size from one uploader: fold shows them as one tile, show lists each (default fold)",
6830
+ default: "fold"
6831
+ },
6832
+ TTHEME_FIND_REMOVE_BG: {
6833
+ doc: "# an opaque picture tried on in find: on cuts the character out with macOS Vision, off leaves it as it is (default on)",
6834
+ default: "on"
6835
+ },
6836
+ TTHEME_FIND_UNBLOCK: {
6837
+ doc: "# when a network blocks a booru by name, 1 sends find through a local proxy that splits the TLS handshake (default 0)",
6838
+ default: "0"
6839
+ },
6840
+ TTHEME_FIND_HOSTS: {
6841
+ 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)',
6842
+ default: ""
6807
6843
  }
6808
6844
  };
6809
6845
  function settingLine(name, value) {
@@ -6812,23 +6848,17 @@ function settingLine(name, value) {
6812
6848
  function settingPattern(name) {
6813
6849
  return new RegExp(String.raw`^#? ?: \$\{${name}[^\n]*$`, "m");
6814
6850
  }
6815
- function appendSetting(content, name, value) {
6851
+ function ensureSetting(content, name) {
6852
+ if (settingPattern(name).test(content)) {
6853
+ return content;
6854
+ }
6855
+ const setting = CONFIG_SETTINGS[name];
6816
6856
  return `${content.replace(/\n*$/, `
6817
6857
  `)}
6818
- ${CONFIG_SETTINGS[name].doc}
6819
- ${settingLine(name, value)}
6858
+ ${setting.doc}
6859
+ ${settingLine(name, setting.default)}
6820
6860
  `;
6821
6861
  }
6822
- function applySetting(content, name, value) {
6823
- const pattern = settingPattern(name);
6824
- if (pattern.test(content)) {
6825
- return content.replace(pattern, () => settingLine(name, value));
6826
- }
6827
- return appendSetting(content, name, value);
6828
- }
6829
- function ensureSetting(content, name) {
6830
- return settingPattern(name).test(content) ? content : appendSetting(content, name, CONFIG_SETTINGS[name].default);
6831
- }
6832
6862
  function configTemplate() {
6833
6863
  const sections = Object.entries(CONFIG_SETTINGS).map(([name, s]) => `${s.doc}
6834
6864
  ${settingLine(name, s.default)}`);
@@ -6837,11 +6867,22 @@ ${settingLine(name, s.default)}`);
6837
6867
  `)}
6838
6868
  `;
6839
6869
  }
6840
- function configFile(content, opts) {
6841
- const seeded = content === "" ? configTemplate() : content;
6842
- const tabbed = applySetting(seeded, "TTHEME_TAB_PALETTE", opts.tabPalette);
6843
- const announced = applySetting(tabbed, "TTHEME_ANNOUNCE", opts.announce ? "1" : "0");
6844
- return ensureSetting(ensureSetting(announced, "TTHEME_FX"), "TTHEME_SORT");
6870
+ function withSetting(content, name, value) {
6871
+ const line = settingLine(name, value);
6872
+ const pattern = settingPattern(name);
6873
+ if (pattern.test(content)) {
6874
+ return content.replace(pattern, line);
6875
+ }
6876
+ return `${content.replace(/\n*$/, `
6877
+ `)}
6878
+ ${line}
6879
+ `;
6880
+ }
6881
+ function configFile(content) {
6882
+ if (content === "") {
6883
+ return configTemplate();
6884
+ }
6885
+ return Object.keys(CONFIG_SETTINGS).reduce(ensureSetting, content);
6845
6886
  }
6846
6887
  function kittyBlock(palette) {
6847
6888
  return palette ? `include themes/${palette}.conf` : "";
@@ -6854,7 +6895,7 @@ import = ["${themePath}"]` : "";
6854
6895
  // src/emit/shell.ts
6855
6896
  function palettesZsh(palettes) {
6856
6897
  for (const p of palettes) {
6857
- for (const field of [p.name, p.group, p.native ?? "", p.ansiSource, p.booru ?? ""]) {
6898
+ for (const field of [p.name, p.group, p.native ?? "", p.ansiSource]) {
6858
6899
  if (/["$`\\]/.test(field)) {
6859
6900
  throw new Error(`${p.name}: "${field}" contains a character that breaks zsh quoting`);
6860
6901
  }
@@ -6896,11 +6937,6 @@ function palettesZsh(palettes) {
6896
6937
  "typeset -gA TTHEME_SRC=(",
6897
6938
  ...palettes.map((p) => entry(p, p.ansiSource)),
6898
6939
  ")",
6899
- "",
6900
- "# the safebooru tag preview searches when a palette has no background yet",
6901
- "typeset -gA TTHEME_BOORU=(",
6902
- ...palettes.filter((p) => p.booru).map((p) => entry(p, p.booru ?? "")),
6903
- ")",
6904
6940
  ""
6905
6941
  ].join(`
6906
6942
  `);
@@ -7005,7 +7041,8 @@ ${themes.length} themes (${rotation(themes).length} in the new-tab rotation) ->
7005
7041
  }
7006
7042
 
7007
7043
  // src/find.ts
7008
- import { existsSync as existsSync3, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync4, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
7044
+ import { spawn as spawn2 } from "node:child_process";
7045
+ import { existsSync as existsSync3, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync5, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "node:fs";
7009
7046
  import { tmpdir } from "node:os";
7010
7047
  import { dirname as dirname3, join as join7 } from "node:path";
7011
7048
 
@@ -7029,12 +7066,21 @@ function decodePng(bytes) {
7029
7066
  const png = import_pngjs.PNG.sync.read(Buffer.from(bytes));
7030
7067
  return { width: png.width, height: png.height, data: new Uint8Array(png.data) };
7031
7068
  }
7032
- function decodeImage(bytes) {
7069
+ function decodeImage(bytes, limit) {
7033
7070
  if (isPng(bytes)) {
7071
+ const head = pngHead(bytes);
7072
+ if (head && head.width * head.height > limit) {
7073
+ throw new Error(`${head.width}×${head.height} is over ${limit / 1e6} megapixels`);
7074
+ }
7034
7075
  return decodePng(bytes);
7035
7076
  }
7036
7077
  if (bytes[0] === 255 && bytes[1] === 216) {
7037
- const jpeg = import_jpeg_js.decode(bytes, { useTArray: true, formatAsRGBA: true, maxMemoryUsageInMB: 1024 });
7078
+ const jpeg = import_jpeg_js.decode(bytes, {
7079
+ useTArray: true,
7080
+ formatAsRGBA: true,
7081
+ maxResolutionInMP: limit / 1e6,
7082
+ maxMemoryUsageInMB: 1024
7083
+ });
7038
7084
  return { width: jpeg.width, height: jpeg.height, data: jpeg.data };
7039
7085
  }
7040
7086
  throw new Error("not a PNG or JPEG image");
@@ -7066,9 +7112,8 @@ function pngHead(bytes) {
7066
7112
  if (bytes[25] === 4 || bytes[25] === 6) {
7067
7113
  return { width, height, alpha: true };
7068
7114
  }
7069
- const trns = find(bytes, "tRNS");
7070
7115
  const idat = find(bytes, "IDAT");
7071
- return { width, height, alpha: trns !== -1 && (idat === -1 || trns < idat) };
7116
+ return { width, height, alpha: find(idat === -1 ? bytes : bytes.subarray(0, idat), "tRNS") !== -1 };
7072
7117
  }
7073
7118
  function alphaBox(image, threshold = CLEAR) {
7074
7119
  let x0 = image.width;
@@ -7370,14 +7415,30 @@ function origins(configHome) {
7370
7415
  }
7371
7416
 
7372
7417
  // src/booru.ts
7418
+ import { setDefaultAutoSelectFamilyAttemptTimeout } from "node:net";
7373
7419
  import { homedir } from "node:os";
7374
7420
  import { join as join4 } from "node:path";
7421
+ import { setTimeout as sleep } from "node:timers/promises";
7375
7422
  var PAGE = 100;
7376
- var SAFE = new Set(["safe", "general", "s", "g"]);
7423
+ var MAX_PIXELS = 25000000;
7424
+ var RATINGS = ["safe", "questionable", "explicit"];
7425
+ var BLOCKS = ["nudity", "underwear"];
7426
+ var EXPOSED = {
7427
+ nudity: new Set(["nude", "naked", "topless", "bottomless", "nipples", "naked_towel", "undressing"]),
7428
+ underwear: new Set(["underwear", "panties", "pantsu", "bra", "lingerie", "pantyshot"])
7429
+ };
7430
+ var MOEBOORU = { safe: "s", questionable: "q", explicit: "e" };
7377
7431
  var MIRRORS = new Set(["danbooru", "gelbooru", "konachan", "yande.re", "sankaku"]);
7378
7432
  var HEAD = 8191;
7379
7433
  var TIMEOUT = 20000;
7434
+ var CONNECT = 3000;
7435
+ var MAX_WAIT = 60000;
7436
+ var TRIES = 3;
7380
7437
  var AGENT = `ttheme/${package_default.version} (+${package_default.homepage})`;
7438
+ var paused = new Map;
7439
+ var HOSTS = hostMap(process.env.TTHEME_FIND_HOSTS);
7440
+ var CUTOUTS = cutoutMap(process.env.TTHEME_FIND_CUTOUTS);
7441
+ setDefaultAutoSelectFamilyAttemptTimeout(CONNECT);
7381
7442
  function absolute(url) {
7382
7443
  return url.startsWith("//") ? `https:${url}` : url;
7383
7444
  }
@@ -7390,26 +7451,45 @@ function records(text) {
7390
7451
  return [];
7391
7452
  }
7392
7453
  const raw = JSON.parse(body);
7393
- return Array.isArray(raw) ? raw : [];
7454
+ if (Array.isArray(raw)) {
7455
+ return raw;
7456
+ }
7457
+ const posts = raw.posts;
7458
+ return Array.isArray(posts) ? posts : [];
7459
+ }
7460
+ function tagTypes(text) {
7461
+ const body = text.trim();
7462
+ const raw = body ? JSON.parse(body) : {};
7463
+ const tags = raw.tags ?? {};
7464
+ return new Map(Object.entries(tags).map(([name, type]) => [name, String(type)]));
7465
+ }
7466
+ function version(url, width, height) {
7467
+ const file = absolute(String(url ?? ""));
7468
+ return { file, width: Number(width) || 0, height: Number(height) || 0, ext: extension(file) };
7394
7469
  }
7395
- function post(p, file, ext, owner, md5) {
7396
- const preview = absolute(String(p.preview_url ?? ""));
7397
- if (!file || !preview || !Number(p.id)) {
7470
+ function post(p, raw) {
7471
+ const preview = absolute(String(raw.preview ?? ""));
7472
+ if (!raw.file || !preview || !Number(p.id)) {
7398
7473
  return [];
7399
7474
  }
7475
+ const width = Number(p.width ?? p.image_width) || 0;
7476
+ const height = Number(p.height ?? p.image_height) || 0;
7400
7477
  return [
7401
7478
  {
7402
7479
  id: Number(p.id),
7403
- width: Number(p.width) || 0,
7404
- height: Number(p.height) || 0,
7405
- file,
7480
+ width,
7481
+ height,
7482
+ file: raw.file,
7406
7483
  preview,
7407
- ext,
7408
- owner: String(owner ?? ""),
7484
+ ext: raw.ext,
7485
+ owner: String(raw.owner ?? ""),
7486
+ artist: raw.artist,
7487
+ score: Number(p.score) || 0,
7409
7488
  rating: String(p.rating ?? ""),
7410
- md5: String(md5 ?? ""),
7489
+ md5: String(raw.md5 ?? ""),
7411
7490
  source: String(p.source ?? ""),
7412
- tags: String(p.tags ?? "").split(/\s+/).filter(Boolean)
7491
+ tags: raw.tags.split(/\s+/).filter(Boolean),
7492
+ smaller: raw.versions.filter((v) => v.file && v.width > 0 && v.height > 0 && v.width * v.height < width * height)
7413
7493
  }
7414
7494
  ];
7415
7495
  }
@@ -7417,54 +7497,239 @@ function parseGelbooru(text, origin = "https://safebooru.org") {
7417
7497
  return records(text).flatMap((p) => {
7418
7498
  const stored = p.directory && p.image ? `${origin}/images/${p.directory}/${p.image}` : "";
7419
7499
  const file = absolute(String(p.file_url || stored));
7420
- return post(p, file, extension(file), p.owner, p.hash ?? p.md5);
7500
+ return post(p, {
7501
+ preview: p.preview_url,
7502
+ file,
7503
+ ext: extension(file),
7504
+ owner: p.owner,
7505
+ artist: "",
7506
+ md5: p.hash ?? p.md5,
7507
+ tags: String(p.tags ?? ""),
7508
+ versions: [version(p.sample_url, p.sample_width, p.sample_height)]
7509
+ });
7421
7510
  });
7422
7511
  }
7423
7512
  function parseMoebooru(text) {
7513
+ const types = tagTypes(text);
7424
7514
  return records(text).flatMap((p) => {
7425
7515
  const file = absolute(String(p.file_url ?? ""));
7426
- return post(p, file, String(p.file_ext || extension(file)).toLowerCase(), p.author, p.md5);
7516
+ const tags = String(p.tags ?? "");
7517
+ return post(p, {
7518
+ preview: p.preview_url,
7519
+ file,
7520
+ ext: String(p.file_ext || extension(file)).toLowerCase(),
7521
+ owner: p.author,
7522
+ artist: tags.split(/\s+/).find((tag) => types.get(tag) === "artist") ?? "",
7523
+ md5: p.md5,
7524
+ tags,
7525
+ versions: [
7526
+ version(p.jpeg_url, p.jpeg_width, p.jpeg_height),
7527
+ version(p.sample_url, p.sample_width, p.sample_height)
7528
+ ]
7529
+ });
7530
+ });
7531
+ }
7532
+ function parseDanbooru(text) {
7533
+ return records(text).flatMap((p) => {
7534
+ const file = absolute(String(p.file_url ?? ""));
7535
+ const asset = p.media_asset ?? {};
7536
+ return post(p, {
7537
+ preview: p.preview_file_url,
7538
+ file,
7539
+ ext: String(p.file_ext || extension(file)).toLowerCase(),
7540
+ owner: "",
7541
+ artist: String(p.tag_string_artist ?? "").split(/\s+/)[0] ?? "",
7542
+ md5: p.md5,
7543
+ tags: String(p.tag_string ?? ""),
7544
+ 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)
7545
+ });
7427
7546
  });
7428
7547
  }
7429
7548
  function parseCount(xml) {
7430
7549
  return Number(/count="(\d+)"/.exec(xml)?.[1] ?? 0);
7431
7550
  }
7432
- function gelbooru(key, name, origin, cutouts, vouched, ansi) {
7433
- const api = (params) => `${origin}/index.php?${new URLSearchParams({ page: "dapi", s: "post", q: "index", ...params })}`;
7551
+ function parseCounts(text) {
7552
+ const raw = text.trim() ? JSON.parse(text) : {};
7553
+ return Number(raw.counts?.posts) || 0;
7554
+ }
7555
+ function cutoutMap(spec) {
7556
+ const cutouts = new Map;
7557
+ for (const entry of (spec ?? "").split(/\s+/).filter(Boolean)) {
7558
+ const at2 = entry.indexOf("=");
7559
+ if (at2 > 0) {
7560
+ cutouts.set(entry.slice(0, at2), entry.slice(at2 + 1).split(",").filter(Boolean));
7561
+ }
7562
+ }
7563
+ return cutouts;
7564
+ }
7565
+ function hostMap(spec) {
7566
+ const hosts = new Map;
7567
+ for (const entry of (spec ?? "").split(/[\s,]+/).filter(Boolean)) {
7568
+ const at2 = entry.indexOf("=");
7569
+ const key = entry.slice(0, at2);
7570
+ try {
7571
+ const url = new URL(entry.slice(at2 + 1));
7572
+ if (key && url.protocol === "https:") {
7573
+ hosts.set(key, url.origin);
7574
+ }
7575
+ } catch {}
7576
+ }
7577
+ return hosts;
7578
+ }
7579
+ function chosen(value, all, fallback, none) {
7580
+ const words = (value ?? "").split(/\s+/);
7581
+ if (none !== undefined && words.includes(none)) {
7582
+ return [];
7583
+ }
7584
+ const picked = all.filter((item) => words.includes(item));
7585
+ return picked.length > 0 ? picked : [...fallback];
7586
+ }
7587
+ function ratingSet(value) {
7588
+ return chosen(value, RATINGS, ["safe"]);
7589
+ }
7590
+ function blockSet(value) {
7591
+ return chosen(value, BLOCKS, BLOCKS, "none");
7592
+ }
7593
+ function tiers(safe, questionable, explicit) {
7594
+ return { safe: new Set(safe), questionable: new Set(questionable), explicit: new Set(explicit) };
7595
+ }
7596
+ function based(spec) {
7597
+ const origin = HOSTS.get(spec.key);
7598
+ const cutouts = CUTOUTS.get(spec.key);
7434
7599
  return {
7435
- key,
7436
- name,
7437
- origin,
7438
- cutouts,
7439
- vouched,
7440
- ansi,
7600
+ ...spec,
7601
+ origin: origin ?? spec.origin,
7602
+ moved: origin !== undefined && origin !== spec.origin,
7603
+ cutouts: cutouts ?? spec.cutouts,
7604
+ vouched: spec.vouched && cutouts === undefined
7605
+ };
7606
+ }
7607
+ function anyOf(tags, grouped) {
7608
+ if (tags.length < 2) {
7609
+ return tags.join("");
7610
+ }
7611
+ return grouped ? `( ${tags.join(" ~ ")} )` : tags.map((tag) => `~${tag}`).join(" ");
7612
+ }
7613
+ function gelbooru(raw) {
7614
+ const spec = based(raw);
7615
+ const api = (params) => `${spec.origin}/index.php?${new URLSearchParams({ page: "dapi", s: "post", q: "index", ...params })}`;
7616
+ return {
7617
+ ...spec,
7618
+ cutouts: anyOf(spec.cutouts, true),
7619
+ ratings: tiers(["safe", "general"], ["questionable"], ["explicit"]),
7620
+ rate: () => "",
7621
+ best: "sort:score:desc",
7622
+ tagBudget: Number.POSITIVE_INFINITY,
7441
7623
  postsUrl: (tags, page) => api({ json: "1", limit: String(PAGE), pid: String(page), tags }),
7442
7624
  countUrl: (tags) => api({ limit: "0", tags }),
7443
7625
  postUrl: (id) => api({ json: "1", id: String(id) }),
7444
- pageUrl: (id) => `${origin}/index.php?page=post&s=view&id=${id}`,
7445
- parse: (text) => parseGelbooru(text, origin)
7626
+ pageUrl: (id) => `${spec.origin}/index.php?page=post&s=view&id=${id}`,
7627
+ parse: (text) => parseGelbooru(text, spec.origin),
7628
+ count: parseCount
7446
7629
  };
7447
7630
  }
7448
- function moebooru(key, name, origin, cutouts, vouched, ansi) {
7449
- const safe = (tags) => `${tags} rating:s`;
7631
+ function moebooru(raw) {
7632
+ const spec = based(raw);
7633
+ const params = (rest) => new URLSearchParams({ api_version: "2", include_tags: "1", ...rest });
7450
7634
  return {
7451
- key,
7452
- name,
7453
- origin,
7454
- cutouts,
7455
- vouched,
7456
- ansi,
7457
- postsUrl: (tags, page) => `${origin}/post.json?${new URLSearchParams({ limit: String(PAGE), page: String(page + 1), tags: safe(tags) })}`,
7458
- countUrl: (tags) => `${origin}/post.xml?${new URLSearchParams({ limit: "1", tags: safe(tags) })}`,
7459
- postUrl: (id) => `${origin}/post.json?${new URLSearchParams({ tags: `id:${id}` })}`,
7460
- pageUrl: (id) => `${origin}/post/show/${id}`,
7461
- parse: parseMoebooru
7635
+ ...spec,
7636
+ cutouts: anyOf(spec.cutouts, false),
7637
+ ratings: tiers([MOEBOORU.safe], [MOEBOORU.questionable], [MOEBOORU.explicit]),
7638
+ rate: (levels) => {
7639
+ const [only] = levels;
7640
+ if (levels.length === 1 && only) {
7641
+ return `rating:${MOEBOORU[only]}`;
7642
+ }
7643
+ const missing = RATINGS.filter((level) => !levels.includes(level));
7644
+ const [left] = missing;
7645
+ return missing.length === 1 && left ? `-rating:${MOEBOORU[left]}` : "";
7646
+ },
7647
+ best: "order:score",
7648
+ tagBudget: Number.POSITIVE_INFINITY,
7649
+ postsUrl: (tags, page) => `${spec.origin}/post.json?${params({ limit: String(PAGE), page: String(page + 1), tags })}`,
7650
+ countUrl: (tags) => `${spec.origin}/post.xml?${new URLSearchParams({ limit: "1", tags })}`,
7651
+ postUrl: (id) => `${spec.origin}/post.json?${params({ tags: `id:${id}` })}`,
7652
+ pageUrl: (id) => `${spec.origin}/post/show/${id}`,
7653
+ parse: parseMoebooru,
7654
+ count: parseCount
7655
+ };
7656
+ }
7657
+ function danbooru(raw) {
7658
+ const spec = based(raw);
7659
+ return {
7660
+ ...spec,
7661
+ cutouts: anyOf(spec.cutouts, false),
7662
+ ratings: tiers(["g"], ["s", "q"], ["e"]),
7663
+ rate: () => "",
7664
+ best: "order:score",
7665
+ tagBudget: 2,
7666
+ postsUrl: (tags, page) => `${spec.origin}/posts.json?${new URLSearchParams({ limit: String(PAGE), page: String(page + 1), tags })}`,
7667
+ countUrl: (tags) => `${spec.origin}/counts/posts.json?${new URLSearchParams({ tags })}`,
7668
+ postUrl: (id) => `${spec.origin}/posts.json?${new URLSearchParams({ limit: "1", tags: `id:${id}` })}`,
7669
+ pageUrl: (id) => `${spec.origin}/posts/${id}`,
7670
+ parse: parseDanbooru,
7671
+ count: parseCounts
7462
7672
  };
7463
7673
  }
7674
+ function siteNamed(name) {
7675
+ return SITES.find((site) => site.key === name || site.name === name || new URL(site.origin).host === name);
7676
+ }
7677
+ function tagsOf(query) {
7678
+ return query.split(/\s+/).filter(Boolean).length;
7679
+ }
7680
+ function postRef(text, fallback) {
7681
+ const query = text.trim();
7682
+ if (/^\d+$/.test(query)) {
7683
+ return { site: fallback, id: Number(query) };
7684
+ }
7685
+ const named = /^([\w.]+):(\d+)$/.exec(query);
7686
+ if (named) {
7687
+ const site2 = SITES.find((s) => s.key === named[1] || s.name === named[1]);
7688
+ return site2 && { site: site2, id: Number(named[2]) };
7689
+ }
7690
+ let url;
7691
+ try {
7692
+ url = new URL(query);
7693
+ } catch {
7694
+ return;
7695
+ }
7696
+ const site = siteNamed(url.host);
7697
+ const id = Number(url.searchParams.get("id") ?? url.pathname.split("/").filter(Boolean).at(-1));
7698
+ return site && id > 0 ? { site, id } : undefined;
7699
+ }
7464
7700
  var SITES = [
7465
- gelbooru("safebooru", "safebooru", "https://safebooru.org", "( transparent_background ~ vector_trace )", false, 4),
7466
- moebooru("yande", "yande.re", "https://yande.re", "transparent_png", true, 5),
7467
- moebooru("konachan", "konachan", "https://konachan.net", "~transparent ~vector", false, 6)
7701
+ gelbooru({
7702
+ key: "safebooru",
7703
+ name: "safebooru",
7704
+ origin: "https://safebooru.org",
7705
+ cutouts: ["transparent_background", "vector_trace"],
7706
+ vouched: false,
7707
+ ansi: 4
7708
+ }),
7709
+ moebooru({
7710
+ key: "yande",
7711
+ name: "yande.re",
7712
+ origin: "https://yande.re",
7713
+ cutouts: ["transparent_png"],
7714
+ vouched: true,
7715
+ ansi: 5
7716
+ }),
7717
+ moebooru({
7718
+ key: "konachan",
7719
+ name: "konachan",
7720
+ origin: "https://konachan.net",
7721
+ cutouts: ["transparent", "vector"],
7722
+ vouched: false,
7723
+ ansi: 6
7724
+ }),
7725
+ danbooru({
7726
+ key: "danbooru",
7727
+ name: "danbooru",
7728
+ origin: "https://safebooru.donmai.us",
7729
+ cutouts: ["transparent_background"],
7730
+ vouched: false,
7731
+ ansi: 2
7732
+ })
7468
7733
  ];
7469
7734
  function originHost(source) {
7470
7735
  let host;
@@ -7475,8 +7740,17 @@ function originHost(source) {
7475
7740
  }
7476
7741
  return host.split(".").slice(-2).join(".");
7477
7742
  }
7478
- function safe(post2) {
7479
- return SAFE.has(post2.rating);
7743
+ function exposed(post2, blocks = BLOCKS) {
7744
+ return post2.tags.filter((tag) => blocks.some((block2) => EXPOSED[block2].has(tag)));
7745
+ }
7746
+ function rated(site, post2, levels = ["safe"]) {
7747
+ return levels.some((level) => site.ratings[level].has(post2.rating));
7748
+ }
7749
+ function rendition(post2) {
7750
+ return [post2, ...post2.smaller].find((v) => v.width * v.height <= MAX_PIXELS);
7751
+ }
7752
+ function mirrored(owner) {
7753
+ return MIRRORS.has(owner);
7480
7754
  }
7481
7755
  function mates(owners) {
7482
7756
  const byOwner = new Map;
@@ -7491,21 +7765,50 @@ function mates(owners) {
7491
7765
  function cacheDir(site) {
7492
7766
  return join4(process.env.XDG_CACHE_HOME ?? join4(homedir(), ".cache"), "ttheme", site.key);
7493
7767
  }
7768
+ function retryAfter(value, now) {
7769
+ if (value !== null && /^\s*\d+\s*$/.test(value)) {
7770
+ return Number(value) * 1000;
7771
+ }
7772
+ const at2 = value === null ? Number.NaN : Date.parse(value);
7773
+ return Number.isNaN(at2) ? MAX_WAIT : Math.max(0, at2 - now);
7774
+ }
7775
+ function pausedUntil(site) {
7776
+ return paused.get(site.key) ?? 0;
7777
+ }
7778
+ function challenged(response) {
7779
+ return (response.headers.get("server") ?? "").startsWith("cloudflare") && response.headers.get("cf-mitigated") === "challenge";
7780
+ }
7494
7781
  async function get(site, url, signal, headers = {}, timeout = TIMEOUT) {
7495
- const response = await fetch(url, {
7496
- headers: { "User-Agent": AGENT, Referer: `${site.origin}/`, ...headers },
7497
- signal: timeout ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
7498
- });
7499
- if (!response.ok) {
7500
- throw new Error(`${site.name} answered ${response.status}`);
7782
+ for (let tries = 1;; tries++) {
7783
+ const wait = pausedUntil(site) - Date.now();
7784
+ if (wait > 0) {
7785
+ await sleep(wait, undefined, { signal });
7786
+ }
7787
+ const response = await fetch(url, {
7788
+ headers: { "User-Agent": AGENT, Referer: `${site.origin}/`, ...headers },
7789
+ signal: timeout ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : signal
7790
+ });
7791
+ if (response.status === 429 && tries < TRIES) {
7792
+ await response.body?.cancel();
7793
+ const delay = retryAfter(response.headers.get("retry-after"), Date.now());
7794
+ if (delay > MAX_WAIT) {
7795
+ throw new Error(`${site.name} asks to wait ${Math.ceil(delay / 1000)}s`);
7796
+ }
7797
+ paused.set(site.key, Math.max(pausedUntil(site), Date.now() + delay));
7798
+ continue;
7799
+ }
7800
+ if (!response.ok) {
7801
+ await response.body?.cancel();
7802
+ throw new Error(challenged(response) ? `${site.name} is behind a Cloudflare challenge` : `${site.name} answered ${response.status}`);
7803
+ }
7804
+ return response;
7501
7805
  }
7502
- return response;
7503
7806
  }
7504
7807
  async function fetchPosts(site, tags, page, signal) {
7505
7808
  return site.parse(await (await get(site, site.postsUrl(tags, page), signal)).text());
7506
7809
  }
7507
7810
  async function fetchCount(site, tags, signal) {
7508
- return parseCount(await (await get(site, site.countUrl(tags), signal)).text());
7811
+ return site.count(await (await get(site, site.countUrl(tags), signal)).text());
7509
7812
  }
7510
7813
  async function fetchPost(site, id, signal) {
7511
7814
  const [found] = site.parse(await (await get(site, site.postUrl(id), signal)).text());
@@ -7620,8 +7923,66 @@ function search(palettes, query) {
7620
7923
  return palettes.filter((p) => [p.name, p.group, p.native ?? "", p.ansiSource].some((field) => field.toLowerCase().includes(needle)));
7621
7924
  }
7622
7925
 
7926
+ // src/cutout.ts
7927
+ import { spawn } from "node:child_process";
7928
+ import { readFileSync as readFileSync3 } from "node:fs";
7929
+ var TIMEOUT2 = 30000;
7930
+ var LEAST = 3;
7931
+ var MOST = 97;
7932
+ var SCRIPT = `ObjC.import('Vision')
7933
+ ObjC.import('CoreImage')
7934
+ ObjC.import('CoreGraphics')
7935
+ function run(argv) {
7936
+ const error = Ref()
7937
+ const image = $.CIImage.imageWithContentsOfURL($.NSURL.fileURLWithPath(argv[0]))
7938
+ if (!image) throw new Error('unreadable image')
7939
+ const handler = $.VNImageRequestHandler.alloc.initWithCIImageOptions(image, $({}))
7940
+ const request = $.VNGenerateForegroundInstanceMaskRequest.alloc.init
7941
+ if (!handler.performRequestsError($([request]), error)) throw new Error('vision failed')
7942
+ if (request.results.count === 0) throw new Error('no foreground')
7943
+ const found = request.results.objectAtIndex(0)
7944
+ const buffer = found.generateMaskedImageOfInstancesFromRequestHandlerCroppedToInstancesExtentError(
7945
+ found.allInstances, handler, false, error)
7946
+ if (!buffer) throw new Error('mask failed')
7947
+ const written = $.CIContext.context.writePNGRepresentationOfImageToURLFormatColorSpaceOptionsError(
7948
+ $.CIImage.imageWithCVPixelBuffer(buffer), $.NSURL.fileURLWithPath(argv[1]), $.kCIFormatRGBA8,
7949
+ $.CGColorSpaceCreateWithName($.kCGColorSpaceSRGB), $({}), error)
7950
+ if (!written) throw new Error('write failed')
7951
+ }`;
7952
+ function canRemoveBackground() {
7953
+ return process.platform === "darwin";
7954
+ }
7955
+ function keepable(clear) {
7956
+ return clear >= LEAST && clear <= MOST;
7957
+ }
7958
+ function removeBackground(input, output, signal) {
7959
+ return new Promise((resolve, reject) => {
7960
+ const child = spawn("osascript", ["-l", "JavaScript", "-e", SCRIPT, input, output], {
7961
+ stdio: ["ignore", "ignore", "pipe"],
7962
+ signal: AbortSignal.any([signal, AbortSignal.timeout(TIMEOUT2)])
7963
+ });
7964
+ let failure = "";
7965
+ child.stderr.on("data", (chunk) => {
7966
+ failure += chunk;
7967
+ });
7968
+ child.on("error", reject);
7969
+ child.on("close", (code) => {
7970
+ if (code !== 0) {
7971
+ reject(new Error(failure.trim().split(`
7972
+ `).at(-1) || `osascript exited ${code}`));
7973
+ return;
7974
+ }
7975
+ try {
7976
+ resolve(decodePng(new Uint8Array(readFileSync3(output))));
7977
+ } catch (error) {
7978
+ reject(error);
7979
+ }
7980
+ });
7981
+ });
7982
+ }
7983
+
7623
7984
  // src/find-screen.ts
7624
- var TILE = { pitch: 25, cols: 22, rows: 9, height: 12 };
7985
+ var TILE = { pitch: 25, cols: 22, rows: 9, height: 13 };
7625
7986
  var MIN = { cols: 25, rows: 16 };
7626
7987
  var TRY_ID = 2 ** 31;
7627
7988
  var BELOW_BG = -1073741826;
@@ -7658,14 +8019,14 @@ function decodeKeys(input) {
7658
8019
  if (m) {
7659
8020
  keys.push(CSI[`${m[1] ?? ""}${m[2] ?? m[3] ?? ""}`] ?? "nop");
7660
8021
  i += 1 + m[0].length;
7661
- } else {
7662
- keys.push("esc");
7663
- i++;
8022
+ continue;
7664
8023
  }
8024
+ keys.push("esc");
8025
+ i++;
7665
8026
  continue;
7666
8027
  }
7667
8028
  keys.push(c === "\r" || c === `
7668
- ` ? "enter" : c === "\t" ? "tab" : c === "\x03" ? "ctrl-c" : c);
8029
+ ` ? "enter" : c === "\t" ? "tab" : c === "\x03" ? "ctrl-c" : c === "" || c === "\b" ? "backspace" : c);
7669
8030
  i++;
7670
8031
  }
7671
8032
  return keys;
@@ -7736,8 +8097,14 @@ function progress(got, size) {
7736
8097
  }
7737
8098
  return size >= 1e6 ? `${(got / 1e6).toFixed(1)}/${(size / 1e6).toFixed(1)} MB` : `${Math.round(got / 1000)}/${Math.round(size / 1000)} KB`;
7738
8099
  }
8100
+ function plain(artist) {
8101
+ return artist.replace(/_\([^)]*\)?$/, "");
8102
+ }
7739
8103
  function dims(tile) {
7740
- return [`${tile.width}×${tile.height}`, Math.max(tile.width, tile.height) < SMALL ? YELLOW : D];
8104
+ return [
8105
+ `${tile.reduced ? "↓" : ""}${tile.width}×${tile.height}`,
8106
+ Math.max(tile.width, tile.height) < SMALL ? YELLOW : D
8107
+ ];
7741
8108
  }
7742
8109
  function foot(line, cols, accent, spec) {
7743
8110
  const keys = [...spec.keys ?? []];
@@ -7860,23 +8227,61 @@ function frameBox(lines, r0, c0, h, w, sgr) {
7860
8227
  }
7861
8228
  lines[r0 + h - 1]?.put(c0, `╰${"─".repeat(w - 2)}╯`, sgr);
7862
8229
  }
7863
- function badge(view) {
7864
- return [` ${view.site} `, `\x1B[7;${30 + view.siteAnsi}m`];
8230
+ function badge(view, label = view.site) {
8231
+ return [` ${label} `, `\x1B[7;${30 + view.siteAnsi}m`];
8232
+ }
8233
+ function tabs(line, cols, view) {
8234
+ const strip = SITES.flatMap((site, i) => {
8235
+ const label = `${site.name}${site.moved ? "*" : ""}`;
8236
+ const tab = site.name === view.site ? badge(view, label) : [` ${label} `, D];
8237
+ return i ? [[" ", ""], tab] : [tab];
8238
+ });
8239
+ const fits = strip.reduce((n, [text]) => n + width(text), 0) <= cols;
8240
+ line.run(0, fits ? strip : [badge(view)]);
7865
8241
  }
7866
8242
  function query(line, cols, view, accent) {
8243
+ if (view.editing !== undefined) {
8244
+ const c2 = line.run(0, [
8245
+ ["⌕ ", accent],
8246
+ [view.editing, ""],
8247
+ ["█", accent]
8248
+ ]);
8249
+ line.put(c2, view.editing ? " enter searches" : ` a tag, a post url or an id — ${view.tag || "esc leaves"}`, D);
8250
+ return;
8251
+ }
7867
8252
  const c = line.run(0, [
7868
8253
  ["⌕ ", accent],
7869
- [view.tag, ""]
8254
+ [view.tag || "nothing yet — / searches", view.tag ? "" : D]
7870
8255
  ]);
7871
- line.put(c, ` ${view.preset}`, D);
8256
+ const allowed = BLOCKS.filter((block2) => !view.block.includes(block2));
8257
+ const state = [
8258
+ view.preset,
8259
+ view.order,
8260
+ ...view.rating.join("+") === "safe" ? [] : [view.rating.join("+")],
8261
+ ...allowed.length > 0 ? [`allows ${allowed.join("+")}`] : [],
8262
+ ...view.unblocked ? ["unblock"] : []
8263
+ ];
8264
+ line.put(c, ` ${state.join(" ")}`, D);
7872
8265
  if (view.total > 0 || !view.searching) {
7873
- const counter = [badge(view), [` ${view.tiles.length}/${view.checked}`, ""]];
8266
+ const counter = [[`${view.tiles.length}/${view.checked}`, ""]];
7874
8267
  if (view.checked < view.total) {
7875
8268
  counter.push([` of ${view.total}`, D]);
7876
8269
  }
7877
8270
  line.right(cols, counter);
7878
8271
  }
7879
8272
  }
8273
+ function transparent(shown) {
8274
+ if (shown.cut === "on") {
8275
+ return [[`cut out · ${shown.clear}% transparent`, GREEN]];
8276
+ }
8277
+ if (shown.cut === "off") {
8278
+ return [["opaque · x cuts out", YELLOW]];
8279
+ }
8280
+ if (shown.cut === "failed") {
8281
+ return [["opaque · no cut-out", YELLOW]];
8282
+ }
8283
+ return [shown.clear > 0 ? [`${shown.clear}% transparent`, GREEN] : ["opaque", YELLOW]];
8284
+ }
7880
8285
  function status(view) {
7881
8286
  if (view.installing !== undefined) {
7882
8287
  return [`installing ${view.palette} ← ${view.site} ${view.installing}`, YELLOW];
@@ -7884,18 +8289,27 @@ function status(view) {
7884
8289
  if (view.error) {
7885
8290
  return [view.error, YELLOW];
7886
8291
  }
8292
+ if (view.waiting !== undefined) {
8293
+ return [`${view.site} asked to slow down · ${view.waiting}s`, YELLOW];
8294
+ }
7887
8295
  if (view.fetching) {
7888
8296
  return [`fetching ${view.fetching.id} · ${progress(view.fetching.got, view.fetching.size)}`, YELLOW];
7889
8297
  }
8298
+ if (view.cutting !== undefined) {
8299
+ return [`cutting out ${view.cutting}`, YELLOW];
8300
+ }
7890
8301
  if (view.preparing !== undefined) {
7891
8302
  return [`preparing ${view.preparing}`, YELLOW];
7892
8303
  }
8304
+ if (view.note) {
8305
+ return [view.note, D];
8306
+ }
7893
8307
  return;
7894
8308
  }
7895
8309
  function grid(lines, images, cols, rows, view, accent) {
7896
8310
  const { perRow, rowsVis } = gridShape(cols, rows);
7897
8311
  query(lines[0], cols, view, accent);
7898
- lines[1]?.put(0, "─".repeat(Math.min(48, cols)), D);
8312
+ tabs(lines[1], cols, view);
7899
8313
  for (let k = 0;k < perRow * rowsVis; k++) {
7900
8314
  const i = view.top * perRow + k;
7901
8315
  const tile = view.tiles[i];
@@ -7915,6 +8329,20 @@ function grid(lines, images, cols, rows, view, accent) {
7915
8329
  if (tile.mates.length > 0) {
7916
8330
  lines[r0 + 10]?.put(c0 + TILE.cols, "≈", accent);
7917
8331
  }
8332
+ const credit = tile.artist ? plain(tile.artist) : tile.owner && `@${tile.owner}`;
8333
+ if (credit) {
8334
+ lines[r0 + 11]?.put(c0 + 1, credit.slice(0, TILE.cols - 8), D);
8335
+ }
8336
+ const marks = [];
8337
+ if (tile.score > 0) {
8338
+ marks.push([`★${tile.score}`, D]);
8339
+ }
8340
+ if (tile.variants > 1) {
8341
+ marks.push([` ×${tile.variants}`, accent]);
8342
+ }
8343
+ if (marks.length > 0) {
8344
+ lines[r0 + 11]?.right(c0 + TILE.cols + 1, marks);
8345
+ }
7918
8346
  }
7919
8347
  const above = view.top * perRow;
7920
8348
  const below = view.tiles.length - (view.top + rowsVis) * perRow;
@@ -7923,7 +8351,7 @@ function grid(lines, images, cols, rows, view, accent) {
7923
8351
  lines[rows - 2]?.put(0, scroll.join(" "), D);
7924
8352
  }
7925
8353
  if (!view.searching && view.tiles.length === 0 && !view.error) {
7926
- 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);
8354
+ 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);
7927
8355
  }
7928
8356
  const line = lines[rows - 1];
7929
8357
  const lead = status(view);
@@ -7941,8 +8369,9 @@ function grid(lines, images, cols, rows, view, accent) {
7941
8369
  keys: [
7942
8370
  ["←↑↓→", "move"],
7943
8371
  ["enter", "try on"],
7944
- ["tab", view.preset === "cutouts" ? "all" : "cutouts"],
7945
- ["p", view.nextSite],
8372
+ ["tab", "site"],
8373
+ ["s", "settings"],
8374
+ ["/", "search"],
7946
8375
  ["?", "keys"]
7947
8376
  ],
7948
8377
  right: ["esc", "back"]
@@ -7955,6 +8384,12 @@ function trial(lines, images, cols, rows, view, accent) {
7955
8384
  }
7956
8385
  const shown = view.shown?.id === tile.id ? view.shown : undefined;
7957
8386
  const meta2 = [badge(view), [" ", ""], [String(tile.id), B], [" ", ""], dims(tile)];
8387
+ if (tile.artist) {
8388
+ meta2.push([` · ${plain(tile.artist)}`, ""]);
8389
+ }
8390
+ if (tile.score > 0) {
8391
+ meta2.push([` · ★${tile.score}`, D]);
8392
+ }
7958
8393
  if (shown) {
7959
8394
  meta2.push([` · ${megabytes(shown.bytes)}`, D]);
7960
8395
  }
@@ -7963,7 +8398,7 @@ function trial(lines, images, cols, rows, view, accent) {
7963
8398
  meta2.push([` · ${tile.origin}`, D]);
7964
8399
  }
7965
8400
  if (shown) {
7966
- meta2.push([" ", ""], shown.clear > 0 ? [`${shown.clear}% transparent`, GREEN] : ["opaque", YELLOW]);
8401
+ meta2.push([" ", ""], ...transparent(shown));
7967
8402
  }
7968
8403
  lines[0]?.run(0, meta2);
7969
8404
  if (tile.mates.length > 0) {
@@ -7983,6 +8418,7 @@ function trial(lines, images, cols, rows, view, accent) {
7983
8418
  keys: view.installing === undefined ? [
7984
8419
  ["←→", "browse"],
7985
8420
  ["enter", "install"],
8421
+ ...view.shown?.cut === "on" || view.shown?.cut === "off" ? [["x", "cut out"]] : [],
7986
8422
  ["?", "keys"]
7987
8423
  ] : [],
7988
8424
  right: view.installing === undefined ? ["esc", "grid"] : undefined
@@ -7992,18 +8428,60 @@ var KEYS = {
7992
8428
  grid: [
7993
8429
  ["move", "←↑↓→ home end pgup pgdn"],
7994
8430
  ["try on", "enter"],
7995
- ["posts", "tab cutouts or every post"],
7996
- ["site", `p ${SITES.map((site) => site.name).join(", ")}`],
8431
+ ["site", `tab ${SITES.map((site) => site.name).join(", ")}`],
8432
+ ["posts", "c cutouts or every post"],
8433
+ ["search", "/ a tag, a post url or an id"],
8434
+ ["unfold", "space a set of ×N"],
8435
+ ["open", "o the post page in a browser"],
8436
+ ["settings", "s rating, block, posts, order, sets, remove bg"],
7997
8437
  ["back", "esc returns to preview"],
7998
8438
  ["close", "? esc"]
7999
8439
  ],
8000
8440
  try: [
8001
8441
  ["browse", "←→"],
8002
8442
  ["install", "enter"],
8443
+ ["cut out", "x the background off or on, on an opaque picture"],
8444
+ ["open", "o the post page in a browser"],
8003
8445
  ["grid", "esc"],
8004
8446
  ["close", "? esc"]
8005
8447
  ]
8006
8448
  };
8449
+ function panel(lines, cols, rows, view, accent) {
8450
+ const at2 = view.panel ?? 0;
8451
+ const label = Math.max(...view.settings.map((row) => row.label.length));
8452
+ const widest = Math.max(...view.settings.map((row) => row.choices.reduce((n, c) => n + c.length + (row.multi ? 6 : 3), 0)));
8453
+ const w = Math.min(cols - 2, Math.max(28, label + widest + 8));
8454
+ const h = view.settings.length + 4;
8455
+ const x = Math.floor((cols - w) / 2);
8456
+ const y = Math.max(2, Math.floor((rows - h) / 2));
8457
+ lines[y]?.put(x, `╭─ settings ${"─".repeat(Math.max(0, w - 13))}╮`);
8458
+ for (let r = y + 1;r < y + h - 1; r++) {
8459
+ lines[r]?.put(x, `│${" ".repeat(w - 2)}│`);
8460
+ }
8461
+ view.settings.forEach((row, i) => {
8462
+ const line = lines[y + 2 + i];
8463
+ line.put(x + 3, row.label, i === at2 ? B : D);
8464
+ let c = x + 5 + label;
8465
+ const on = row.value.split(" ");
8466
+ row.choices.forEach((choice, k) => {
8467
+ const lit = on.includes(choice);
8468
+ const under = row.multi && i === at2 && k === row.cursor ? "4;" : "";
8469
+ const text = row.multi ? ` ${lit ? "[x]" : "[ ]"} ${choice} ` : ` ${choice} `;
8470
+ c = line.put(c, text, lit ? `\x1B[${under}7;${30 + view.siteAnsi}m` : under ? `\x1B[4m${D}` : D) + 1;
8471
+ });
8472
+ });
8473
+ lines[y + h - 1]?.put(x, `╰${"─".repeat(w - 2)}╯`);
8474
+ foot(lines[rows - 1], cols, accent, {
8475
+ badge: "SET",
8476
+ keys: [
8477
+ ["↑↓", "setting"],
8478
+ ["←→", "value"],
8479
+ ["space", "toggle"],
8480
+ ["enter", "save"]
8481
+ ],
8482
+ right: ["esc", "undo"]
8483
+ });
8484
+ }
8007
8485
  function help(lines, cols, rows, view, accent) {
8008
8486
  const keys = KEYS[view.mode];
8009
8487
  const w = Math.min(cols - 2, 50);
@@ -8036,19 +8514,23 @@ function renderFind(view, cols, rows) {
8036
8514
  } else {
8037
8515
  grid(lines, images, cols, rows, view, accent);
8038
8516
  }
8039
- if (view.help) {
8517
+ if (view.help || view.panel !== undefined) {
8040
8518
  const keep = view.mode === "try" ? images : [];
8041
8519
  for (let r = 0;r < rows; r++) {
8042
8520
  lines[r] = new Line(cols);
8043
8521
  }
8044
- help(lines, cols, rows, view, accent);
8522
+ if (view.help) {
8523
+ help(lines, cols, rows, view, accent);
8524
+ } else {
8525
+ panel(lines, cols, rows, view, accent);
8526
+ }
8045
8527
  return { lines: lines.map((l) => l.render()), images: keep };
8046
8528
  }
8047
8529
  return { lines: lines.map((l) => l.render()), images };
8048
8530
  }
8049
8531
 
8050
8532
  // src/palettes.ts
8051
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
8533
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
8052
8534
  import { homedir as homedir2 } from "node:os";
8053
8535
  import { dirname as dirname2, join as join6 } from "node:path";
8054
8536
  var EMITTERS = { ghostty, kitty, alacritty };
@@ -8063,7 +8545,7 @@ function readInstalled(configHome2) {
8063
8545
  if (!existsSync2(path2)) {
8064
8546
  throw new Error(`nothing installed yet at ${path2} — run \`ttheme init\` first`);
8065
8547
  }
8066
- const doc = JSON.parse(readFileSync3(path2, "utf8"));
8548
+ const doc = JSON.parse(readFileSync4(path2, "utf8"));
8067
8549
  if (!Array.isArray(doc?.palettes) || !Array.isArray(doc.terminals)) {
8068
8550
  throw new Error(`${path2} has no palettes or terminals`);
8069
8551
  }
@@ -8158,7 +8640,7 @@ function sync(configHome2, catalog, state) {
8158
8640
  }
8159
8641
  }
8160
8642
  const { file, body } = blockFor(terminal, configHome2, startup);
8161
- const current = existsSync2(file) ? readFileSync3(file, "utf8") : "";
8643
+ const current = existsSync2(file) ? readFileSync4(file, "utf8") : "";
8162
8644
  if (terminal === "alacritty" && current !== "" && !current.includes("# ttheme begin")) {
8163
8645
  continue;
8164
8646
  }
@@ -8185,11 +8667,160 @@ function forget(configHome2, catalog, terminals, names) {
8185
8667
  return removed;
8186
8668
  }
8187
8669
 
8670
+ // src/unblock.ts
8671
+ import { connect, createServer } from "node:net";
8672
+ var SPLIT = 20;
8673
+ var HANDSHAKE = 22;
8674
+ function joined(parts) {
8675
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.length, 0));
8676
+ let at2 = 0;
8677
+ for (const part of parts) {
8678
+ out.set(part, at2);
8679
+ at2 += part.length;
8680
+ }
8681
+ return out;
8682
+ }
8683
+ function reframe(record, at2 = SPLIT) {
8684
+ const body = record.subarray(5);
8685
+ if (record[0] !== HANDSHAKE || body.length < 2) {
8686
+ return [record];
8687
+ }
8688
+ const cut = Math.max(1, Math.min(at2, body.length - 1));
8689
+ const framed = (part) => joined([
8690
+ Uint8Array.from([
8691
+ record[0],
8692
+ record[1],
8693
+ record[2],
8694
+ part.length >> 8,
8695
+ part.length & 255
8696
+ ]),
8697
+ part
8698
+ ]);
8699
+ return [framed(body.subarray(0, cut)), framed(body.subarray(cut))];
8700
+ }
8701
+ function tunnelTo(client, host, port, rest) {
8702
+ const upstream = connect(port, host, () => {
8703
+ upstream.setNoDelay(true);
8704
+ client.write(`HTTP/1.1 200 Connection Established\r
8705
+ \r
8706
+ `);
8707
+ let held = rest;
8708
+ let framed = false;
8709
+ const forward = (chunk) => {
8710
+ if (framed) {
8711
+ upstream.write(chunk);
8712
+ return;
8713
+ }
8714
+ held = joined([held, chunk]);
8715
+ if (held.length < 5) {
8716
+ return;
8717
+ }
8718
+ const length = held[3] * 256 + held[4];
8719
+ if (held[0] !== HANDSHAKE) {
8720
+ framed = true;
8721
+ upstream.write(held);
8722
+ return;
8723
+ }
8724
+ if (held.length < 5 + length) {
8725
+ return;
8726
+ }
8727
+ framed = true;
8728
+ const [first, second] = reframe(held.subarray(0, 5 + length));
8729
+ upstream.write(first);
8730
+ setTimeout(() => {
8731
+ if (second) {
8732
+ upstream.write(second);
8733
+ }
8734
+ upstream.write(held.subarray(5 + length));
8735
+ }, 10);
8736
+ };
8737
+ client.on("data", forward);
8738
+ if (held.length > 0) {
8739
+ const pending = held;
8740
+ held = new Uint8Array(0);
8741
+ forward(pending);
8742
+ }
8743
+ upstream.pipe(client);
8744
+ });
8745
+ upstream.on("error", () => client.destroy());
8746
+ client.on("error", () => upstream.destroy());
8747
+ }
8748
+ function serve(client) {
8749
+ client.setNoDelay(true);
8750
+ let head = new Uint8Array(0);
8751
+ const onData = (chunk) => {
8752
+ head = joined([head, chunk]);
8753
+ const text = Buffer.from(head).toString("latin1");
8754
+ const end = text.indexOf(`\r
8755
+ \r
8756
+ `);
8757
+ if (end === -1) {
8758
+ return;
8759
+ }
8760
+ client.off("data", onData);
8761
+ const target = /^CONNECT ([^ :]+):(\d+)/.exec(text);
8762
+ if (!target) {
8763
+ client.end(`HTTP/1.1 405 Method Not Allowed\r
8764
+ \r
8765
+ `);
8766
+ return;
8767
+ }
8768
+ tunnelTo(client, target[1], Number(target[2]), head.subarray(end + 4));
8769
+ };
8770
+ client.on("data", onData);
8771
+ client.on("error", () => client.destroy());
8772
+ }
8773
+ function tunnel() {
8774
+ const server = createServer(serve);
8775
+ return new Promise((resolve2, reject) => {
8776
+ server.once("error", reject);
8777
+ server.listen(0, "127.0.0.1", () => {
8778
+ resolve2({
8779
+ port: server.address().port,
8780
+ close: () => {
8781
+ server.close();
8782
+ server.unref();
8783
+ }
8784
+ });
8785
+ });
8786
+ });
8787
+ }
8788
+
8188
8789
  // src/find.ts
8790
+ var SETTINGS = [
8791
+ { name: "TTHEME_FIND_RATING", label: "rating", choices: RATINGS, multi: { read: ratingSet } },
8792
+ { name: "TTHEME_FIND_BLOCK", label: "block", choices: BLOCKS, multi: { read: blockSet, none: "none" } },
8793
+ { name: "TTHEME_FIND_POSTS", label: "posts", choices: ["all", "cutouts"] },
8794
+ { name: "TTHEME_FIND_ORDER", label: "order", choices: ["newest", "score"] },
8795
+ { name: "TTHEME_FIND_SETS", label: "sets", choices: ["fold", "show"] },
8796
+ ...canRemoveBackground() ? [{ name: "TTHEME_FIND_REMOVE_BG", label: "remove bg", choices: ["on", "off"] }] : []
8797
+ ];
8798
+ function initial(setting, raw) {
8799
+ if (setting.multi) {
8800
+ return setting.multi.read(raw).join(" ") || setting.multi.none;
8801
+ }
8802
+ return setting.choices.find((choice) => choice === raw) ?? setting.choices[0];
8803
+ }
8189
8804
  var WORKERS = 4;
8805
+ var PRELOAD = 2;
8190
8806
  var SETTLE = 150;
8807
+ var ESCAPE = 30;
8191
8808
  var TRY_WIDTH = 1280;
8192
8809
  var LOOKAHEAD = 1;
8810
+ function blank(key, searching) {
8811
+ return {
8812
+ key,
8813
+ groups: [],
8814
+ sources: [],
8815
+ queue: [],
8816
+ seen: new Set,
8817
+ checked: 0,
8818
+ total: 0,
8819
+ focus: 0,
8820
+ top: 0,
8821
+ searching
8822
+ };
8823
+ }
8193
8824
  function describe(error) {
8194
8825
  if (!(error instanceof Error)) {
8195
8826
  return String(error);
@@ -8201,14 +8832,34 @@ function describe(error) {
8201
8832
  return describe(error.cause);
8202
8833
  }
8203
8834
  const code = error.code;
8204
- return typeof code === "string" ? code : error.message || error.name;
8835
+ if (typeof code === "string") {
8836
+ return code;
8837
+ }
8838
+ const first = (error.message || error.name).split(".")[0] ?? "";
8839
+ return first.length > 56 ? `${first.slice(0, 55)}…` : first;
8205
8840
  }
8206
8841
  function reason(site, error) {
8207
8842
  return `${site.name}: ${describe(error)}`;
8208
8843
  }
8844
+ function incomplete(input) {
8845
+ const at2 = input.lastIndexOf("\x1B");
8846
+ return at2 !== -1 && /^\[?[0-9;]*$/.test(input.slice(at2 + 1));
8847
+ }
8209
8848
  function tick() {
8210
8849
  return new Promise((resolve2) => setImmediate(resolve2));
8211
8850
  }
8851
+ function readCache(site, name) {
8852
+ try {
8853
+ return JSON.parse(readFileSync5(join7(cacheDir(site), name), "utf8"));
8854
+ } catch {
8855
+ return {};
8856
+ }
8857
+ }
8858
+ function writeCache(site, name, data) {
8859
+ mkdirSync4(cacheDir(site), { recursive: true });
8860
+ writeFileSync4(join7(cacheDir(site), name), `${JSON.stringify(data)}
8861
+ `);
8862
+ }
8212
8863
 
8213
8864
  class Finder {
8214
8865
  view;
@@ -8219,12 +8870,18 @@ class Finder {
8219
8870
  rows = process.stdout.rows || 24;
8220
8871
  gen = 0;
8221
8872
  pumping = 0;
8222
- sources = [];
8223
- queue = [];
8224
- seen = new Set;
8873
+ boards = new Map;
8874
+ board;
8225
8875
  posts = new Map;
8876
+ thumbPath = new Map;
8226
8877
  siteIndex = 0;
8878
+ values = new Map(SETTINGS.map((setting) => [setting.name, initial(setting, process.env[setting.name])]));
8879
+ direction = 1;
8880
+ prefetching = 0;
8881
+ inflight = new Map;
8227
8882
  owners = new Map;
8883
+ probed = new Map;
8884
+ unsaved = new Set;
8228
8885
  thumbQueue = [];
8229
8886
  thumbing = 0;
8230
8887
  sent = new Map;
@@ -8232,6 +8889,7 @@ class Finder {
8232
8889
  current;
8233
8890
  fetch;
8234
8891
  settle;
8892
+ partial;
8235
8893
  input = "";
8236
8894
  probeWait;
8237
8895
  done;
@@ -8251,17 +8909,32 @@ class Finder {
8251
8909
  site: this.site.name,
8252
8910
  siteAnsi: this.site.ansi,
8253
8911
  nextSite: this.nextSite.name,
8254
- preset: "cutouts",
8912
+ preset: this.setting("TTHEME_FIND_POSTS") === "all" ? "all" : "cutouts",
8913
+ order: this.setting("TTHEME_FIND_ORDER") === "score" ? "score" : "newest",
8914
+ rating: ratingSet(this.setting("TTHEME_FIND_RATING")),
8915
+ block: blockSet(this.setting("TTHEME_FIND_BLOCK")),
8916
+ sets: this.setting("TTHEME_FIND_SETS") === "show" ? "show" : "fold",
8917
+ unblocked: process.env.TTHEME_FIND_PROXY !== undefined,
8918
+ settings: SETTINGS.map((setting) => ({
8919
+ label: setting.label,
8920
+ choices: [...setting.choices],
8921
+ value: this.setting(setting.name),
8922
+ multi: setting.multi && { none: setting.multi.none },
8923
+ cursor: 0
8924
+ })),
8255
8925
  colors: { cursor: entry.cursor, selection: entry.selection, ansi: entry.ansi },
8256
8926
  tiles: [],
8257
8927
  checked: 0,
8258
8928
  total: 0,
8259
- searching: true,
8929
+ searching: tag !== "",
8260
8930
  focus: 0,
8261
8931
  top: 0,
8262
8932
  mode: "grid",
8263
- help: false
8933
+ help: false,
8934
+ editing: tag === "" ? "" : undefined
8264
8935
  };
8936
+ this.board = blank(this.boardKey, tag !== "");
8937
+ this.boards.set(this.board.key, this.board);
8265
8938
  }
8266
8939
  get signal() {
8267
8940
  return this.session.signal;
@@ -8272,6 +8945,52 @@ class Finder {
8272
8945
  get nextSite() {
8273
8946
  return SITES[(this.siteIndex + 1) % SITES.length];
8274
8947
  }
8948
+ setting(name) {
8949
+ return this.values.get(name);
8950
+ }
8951
+ get boardKey() {
8952
+ return `${this.site.key}|${this.view.preset}|${this.view.order}`;
8953
+ }
8954
+ mark(site, id) {
8955
+ return `${site.key}:${id}`;
8956
+ }
8957
+ origPath(site, id, ext) {
8958
+ return join7(this.scratch, "orig", `${site.key}-${id}.${ext}`);
8959
+ }
8960
+ tileOf(site, post2, variants) {
8961
+ const version2 = rendition(post2) ?? post2;
8962
+ return {
8963
+ id: post2.id,
8964
+ width: version2.width,
8965
+ height: version2.height,
8966
+ reduced: version2 !== post2,
8967
+ owner: mirrored(post2.owner) ? "" : post2.owner,
8968
+ artist: post2.artist,
8969
+ score: post2.score,
8970
+ variants,
8971
+ origin: originHost(post2.source),
8972
+ mates: this.owners.get(site.key)?.get(post2.owner) ?? [],
8973
+ thumb: this.thumbPath.get(this.mark(site, post2.id))
8974
+ };
8975
+ }
8976
+ show() {
8977
+ const site = this.site;
8978
+ const view = this.view;
8979
+ const board = this.board;
8980
+ 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)]);
8981
+ view.checked = board.checked;
8982
+ view.total = board.total;
8983
+ view.searching = board.searching;
8984
+ view.note = board.note;
8985
+ view.focus = Math.max(0, Math.min(board.focus, view.tiles.length - 1));
8986
+ view.top = board.top;
8987
+ }
8988
+ syncSite() {
8989
+ const view = this.view;
8990
+ view.site = this.site.name;
8991
+ view.siteAnsi = this.site.ansi;
8992
+ view.nextSite = this.nextSite.name;
8993
+ }
8275
8994
  async run() {
8276
8995
  const { stdin, stdout } = process;
8277
8996
  stdin.setRawMode(true);
@@ -8282,10 +9001,13 @@ class Finder {
8282
9001
  const exit = new Promise((resolve2) => {
8283
9002
  this.done = resolve2;
8284
9003
  });
9004
+ const clock = setInterval(this.onClock, 250);
8285
9005
  const cell = await this.probe();
8286
9006
  if (cell) {
8287
9007
  this.cell = cell;
8288
- this.search();
9008
+ if (this.view.tag) {
9009
+ this.search();
9010
+ }
8289
9011
  } else {
8290
9012
  this.view.searching = false;
8291
9013
  this.view.error = "this terminal does not report its cell size — find needs kitty graphics";
@@ -8295,6 +9017,8 @@ class Finder {
8295
9017
  this.session.abort();
8296
9018
  this.fetch?.abort();
8297
9019
  clearTimeout(this.settle);
9020
+ clearTimeout(this.partial);
9021
+ clearInterval(clock);
8298
9022
  stdin.off("data", this.onData);
8299
9023
  stdout.off("resize", this.onResize);
8300
9024
  this.write("\x1B_Ga=d,d=A,q=2\x1B\\\x1B[H\x1B[J");
@@ -8334,12 +9058,28 @@ class Finder {
8334
9058
  this.input = this.input.slice(0, at2) + this.input.slice(at2 + 1 + m[0].length);
8335
9059
  this.probeWait({ h: Number(m[1]), w: Number(m[2]) });
8336
9060
  }
9061
+ clearTimeout(this.partial);
9062
+ if (incomplete(this.input)) {
9063
+ this.partial = setTimeout(this.flushKeys, ESCAPE);
9064
+ return;
9065
+ }
9066
+ this.flushKeys();
9067
+ };
9068
+ flushKeys = () => {
8337
9069
  const keys = decodeKeys(this.input);
8338
9070
  this.input = "";
8339
9071
  for (const key of keys) {
8340
9072
  this.key(key);
8341
9073
  }
8342
9074
  };
9075
+ onClock = () => {
9076
+ const left = Math.ceil((pausedUntil(this.site) - Date.now()) / 1000);
9077
+ const waiting = left > 0 ? left : undefined;
9078
+ if (waiting !== this.view.waiting) {
9079
+ this.view.waiting = waiting;
9080
+ this.draw();
9081
+ }
9082
+ };
8343
9083
  onResize = () => {
8344
9084
  this.cols = process.stdout.columns || this.cols;
8345
9085
  this.rows = process.stdout.rows || this.rows;
@@ -8359,6 +9099,14 @@ class Finder {
8359
9099
  if (view.installing !== undefined) {
8360
9100
  return;
8361
9101
  }
9102
+ if (view.editing !== undefined) {
9103
+ this.editKey(key);
9104
+ return;
9105
+ }
9106
+ if (view.panel !== undefined) {
9107
+ this.panelKey(key);
9108
+ return;
9109
+ }
8362
9110
  if (view.help) {
8363
9111
  if (key === "?" || key === "esc") {
8364
9112
  view.help = false;
@@ -8407,29 +9155,265 @@ class Finder {
8407
9155
  this.select();
8408
9156
  return;
8409
9157
  }
8410
- if (key === "tab") {
9158
+ if (key === "c") {
8411
9159
  view.preset = view.preset === "cutouts" ? "all" : "cutouts";
8412
- this.search();
9160
+ this.turn();
8413
9161
  return;
8414
9162
  }
8415
- if (key === "p") {
9163
+ if (key === "tab") {
8416
9164
  this.siteIndex = (this.siteIndex + 1) % SITES.length;
8417
- this.current = undefined;
8418
- view.shown = undefined;
8419
- view.site = this.site.name;
8420
- view.siteAnsi = this.site.ansi;
8421
- view.nextSite = this.nextSite.name;
8422
- this.search();
9165
+ this.syncSite();
9166
+ this.turn();
9167
+ return;
9168
+ }
9169
+ if (key === "/") {
9170
+ view.editing = "";
9171
+ this.draw();
9172
+ return;
9173
+ }
9174
+ if (key === " ") {
9175
+ this.unfold();
9176
+ return;
9177
+ }
9178
+ if (key === "o") {
9179
+ this.openPage();
9180
+ return;
9181
+ }
9182
+ if (key === "s") {
9183
+ view.panel = 0;
9184
+ this.draw();
8423
9185
  return;
8424
9186
  }
8425
9187
  if (key === "esc") {
8426
9188
  this.finish(2);
8427
9189
  }
8428
9190
  }
9191
+ panelKey(key) {
9192
+ const view = this.view;
9193
+ const at2 = view.panel ?? 0;
9194
+ const row = view.settings[at2];
9195
+ if (key === "up" || key === "down") {
9196
+ view.panel = Math.max(0, Math.min(view.settings.length - 1, at2 + (key === "up" ? -1 : 1)));
9197
+ this.draw();
9198
+ return;
9199
+ }
9200
+ if ((key === "left" || key === "right") && row) {
9201
+ const step = key === "left" ? -1 : 1;
9202
+ if (row.multi) {
9203
+ row.cursor = (row.cursor + step + row.choices.length) % row.choices.length;
9204
+ } else {
9205
+ const index = row.choices.indexOf(row.value);
9206
+ row.value = row.choices[(index + step + row.choices.length) % row.choices.length];
9207
+ }
9208
+ this.draw();
9209
+ return;
9210
+ }
9211
+ if (key === " " && row?.multi) {
9212
+ const on = row.value.split(" ");
9213
+ const next = row.choices.filter((choice, k) => k === row.cursor ? !on.includes(choice) : on.includes(choice));
9214
+ const empty = row.multi.none;
9215
+ if (next.length > 0 || empty !== undefined) {
9216
+ row.value = next.length > 0 ? next.join(" ") : empty;
9217
+ this.draw();
9218
+ }
9219
+ return;
9220
+ }
9221
+ if (key === "esc") {
9222
+ view.settings.forEach((setting, i) => {
9223
+ setting.value = this.setting(SETTINGS[i]?.name ?? "");
9224
+ });
9225
+ view.panel = undefined;
9226
+ this.draw();
9227
+ return;
9228
+ }
9229
+ if (key === "enter" || key === "alt-c") {
9230
+ view.panel = undefined;
9231
+ this.adopt();
9232
+ }
9233
+ }
9234
+ adopt() {
9235
+ const view = this.view;
9236
+ const before = SETTINGS.map((setting) => this.setting(setting.name));
9237
+ view.settings.forEach((row, i) => {
9238
+ const setting = SETTINGS[i];
9239
+ if (setting) {
9240
+ this.values.set(setting.name, row.value);
9241
+ }
9242
+ });
9243
+ const changed = SETTINGS.filter((setting, i) => this.setting(setting.name) !== before[i]);
9244
+ if (changed.length === 0) {
9245
+ this.draw();
9246
+ return;
9247
+ }
9248
+ this.save(changed);
9249
+ view.rating = ratingSet(this.setting("TTHEME_FIND_RATING"));
9250
+ view.block = blockSet(this.setting("TTHEME_FIND_BLOCK"));
9251
+ view.sets = this.setting("TTHEME_FIND_SETS") === "show" ? "show" : "fold";
9252
+ view.preset = this.setting("TTHEME_FIND_POSTS") === "all" ? "all" : "cutouts";
9253
+ view.order = this.setting("TTHEME_FIND_ORDER") === "score" ? "score" : "newest";
9254
+ this.boards.clear();
9255
+ this.current = undefined;
9256
+ view.shown = undefined;
9257
+ view.mode = "grid";
9258
+ this.search();
9259
+ }
9260
+ save(changed) {
9261
+ const path2 = join7(this.home, "ttheme", "config.zsh");
9262
+ try {
9263
+ const before = existsSync3(path2) ? readFileSync5(path2, "utf8") : "";
9264
+ const after = changed.reduce((text, setting) => withSetting(text, setting.name, this.setting(setting.name)), before);
9265
+ writeFileSync4(path2, after);
9266
+ } catch (error) {
9267
+ this.view.error = error instanceof Error ? error.message : String(error);
9268
+ }
9269
+ }
9270
+ editKey(key) {
9271
+ const view = this.view;
9272
+ const text = view.editing ?? "";
9273
+ if (key === "esc") {
9274
+ view.editing = undefined;
9275
+ if (view.tag === "") {
9276
+ this.finish(2);
9277
+ return;
9278
+ }
9279
+ this.draw();
9280
+ return;
9281
+ }
9282
+ if (key === "enter") {
9283
+ view.editing = undefined;
9284
+ if (text.trim()) {
9285
+ this.commit(text.trim());
9286
+ return;
9287
+ }
9288
+ if (view.tag === "") {
9289
+ this.finish(2);
9290
+ return;
9291
+ }
9292
+ this.draw();
9293
+ return;
9294
+ }
9295
+ if (key === "backspace") {
9296
+ view.editing = text.slice(0, -1);
9297
+ this.draw();
9298
+ return;
9299
+ }
9300
+ if (key.length === 1 && key >= " ") {
9301
+ view.editing = text + key;
9302
+ this.draw();
9303
+ }
9304
+ }
9305
+ async commit(text) {
9306
+ const ref = postRef(text, this.site);
9307
+ if (ref) {
9308
+ await this.jump(ref.site, ref.id);
9309
+ return;
9310
+ }
9311
+ this.view.tag = text;
9312
+ this.boards.clear();
9313
+ await this.search();
9314
+ }
9315
+ async jump(site, id) {
9316
+ const gen = ++this.gen;
9317
+ this.siteIndex = SITES.indexOf(site);
9318
+ this.syncSite();
9319
+ this.current = undefined;
9320
+ this.view.shown = undefined;
9321
+ this.view.error = undefined;
9322
+ const board = blank(`${site.key}|post:${id}`, true);
9323
+ this.board = board;
9324
+ this.boards.set(board.key, board);
9325
+ this.show();
9326
+ this.draw();
9327
+ try {
9328
+ const post2 = await fetchPost(site, id, this.signal);
9329
+ if (gen !== this.gen) {
9330
+ return;
9331
+ }
9332
+ board.searching = false;
9333
+ if (!post2) {
9334
+ board.error = `${site.name} has no post ${id}`;
9335
+ this.view.error = board.error;
9336
+ } else {
9337
+ this.posts.set(this.mark(site, post2.id), post2);
9338
+ board.groups = [{ posts: [post2], open: false }];
9339
+ board.checked = 1;
9340
+ board.total = 1;
9341
+ this.thumbQueue.push({ site, post: post2 });
9342
+ this.thumbs();
9343
+ this.view.mode = "try";
9344
+ }
9345
+ this.show();
9346
+ if (post2) {
9347
+ this.select();
9348
+ }
9349
+ this.draw();
9350
+ } catch (error) {
9351
+ this.fail(gen, error);
9352
+ }
9353
+ }
9354
+ async turn() {
9355
+ this.current = undefined;
9356
+ this.view.shown = undefined;
9357
+ const known = this.boards.get(this.boardKey);
9358
+ if (!known) {
9359
+ await this.search();
9360
+ return;
9361
+ }
9362
+ this.gen++;
9363
+ this.board = known;
9364
+ this.view.error = known.error;
9365
+ this.show();
9366
+ this.draw();
9367
+ await this.pump();
9368
+ }
9369
+ unfold() {
9370
+ const view = this.view;
9371
+ let index = 0;
9372
+ for (const group of this.board.groups) {
9373
+ const size = group.open ? group.posts.length : 1;
9374
+ if (view.focus < index + size) {
9375
+ if (group.posts.length < 2) {
9376
+ return;
9377
+ }
9378
+ group.open = !group.open;
9379
+ this.board.focus = index;
9380
+ for (const post2 of group.posts) {
9381
+ if (!this.thumbPath.has(this.mark(this.site, post2.id))) {
9382
+ this.thumbQueue.push({ site: this.site, post: post2 });
9383
+ }
9384
+ }
9385
+ this.thumbs();
9386
+ this.show();
9387
+ this.scroll();
9388
+ this.draw();
9389
+ return;
9390
+ }
9391
+ index += size;
9392
+ }
9393
+ }
9394
+ openPage() {
9395
+ const tile = this.view.tiles[this.view.focus];
9396
+ if (!tile) {
9397
+ return;
9398
+ }
9399
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
9400
+ try {
9401
+ spawn2(opener, [this.site.pageUrl(tile.id)], { stdio: "ignore", detached: true }).unref();
9402
+ } catch {}
9403
+ }
8429
9404
  tryKey(key) {
8430
9405
  const view = this.view;
8431
9406
  if (key === "left" || key === "right") {
8432
- this.focus(view.focus + (key === "left" ? -1 : 1));
9407
+ this.direction = key === "left" ? -1 : 1;
9408
+ this.focus(view.focus + this.direction);
9409
+ return;
9410
+ }
9411
+ if (key === "o") {
9412
+ this.openPage();
9413
+ return;
9414
+ }
9415
+ if (key === "x") {
9416
+ this.swap();
8433
9417
  return;
8434
9418
  }
8435
9419
  if (key === "enter") {
@@ -8455,6 +9439,7 @@ class Finder {
8455
9439
  return;
8456
9440
  }
8457
9441
  view.focus = next;
9442
+ this.board.focus = next;
8458
9443
  this.scroll();
8459
9444
  this.pump();
8460
9445
  if (view.mode === "try") {
@@ -8472,28 +9457,50 @@ class Finder {
8472
9457
  if (row > view.top + rowsVis - 1) {
8473
9458
  view.top = row - rowsVis + 1;
8474
9459
  }
9460
+ this.board.top = view.top;
9461
+ }
9462
+ query(site) {
9463
+ const view = this.view;
9464
+ const wanted = [
9465
+ view.tag,
9466
+ site.rate(this.view.rating),
9467
+ view.preset === "cutouts" ? site.cutouts : "",
9468
+ view.order === "score" ? site.best : ""
9469
+ ].filter(Boolean);
9470
+ const kept = [];
9471
+ const dropped = [];
9472
+ for (const part of wanted) {
9473
+ if (tagsOf([...kept, part].join(" ")) <= site.tagBudget) {
9474
+ kept.push(part);
9475
+ } else {
9476
+ dropped.push(part);
9477
+ }
9478
+ }
9479
+ this.board.note = dropped.length > 0 ? `${site.name} takes ${site.tagBudget} tags — ${dropped.join(" ")} left out` : undefined;
9480
+ return kept.join(" ");
8475
9481
  }
8476
9482
  async search() {
8477
9483
  const gen = ++this.gen;
8478
- const view = this.view;
8479
- Object.assign(view, { tiles: [], checked: 0, total: 0, searching: true, focus: 0, top: 0, error: undefined });
8480
- this.seen.clear();
8481
- this.queue = [];
8482
- this.sources = [];
8483
- this.thumbQueue = [];
8484
- this.draw();
8485
9484
  const site = this.site;
8486
- const tags = view.preset === "cutouts" ? `${view.tag} ${site.cutouts}` : view.tag;
9485
+ const board = blank(this.boardKey, true);
9486
+ this.board = board;
9487
+ this.boards.set(board.key, board);
9488
+ this.view.error = undefined;
9489
+ this.show();
9490
+ this.draw();
9491
+ const tags = this.query(site);
8487
9492
  try {
8488
9493
  const [total, owners] = await Promise.all([fetchCount(site, tags, this.signal), this.mateOwners(site)]);
8489
9494
  if (gen !== this.gen) {
8490
9495
  return;
8491
9496
  }
8492
- view.total = total;
8493
- this.sources = [
8494
- ...[...owners.keys()].map((owner) => ({ tags: `${tags} user:${owner}`, page: 0, done: false })),
9497
+ board.total = total;
9498
+ const room = tagsOf(tags) + 1 <= site.tagBudget;
9499
+ board.sources = [
9500
+ ...room ? [...owners.keys()].map((owner) => ({ tags: `${tags} user:${owner}`, page: 0, done: false })) : [],
8495
9501
  { tags, page: 0, done: false }
8496
9502
  ];
9503
+ this.show();
8497
9504
  this.draw();
8498
9505
  await this.pump();
8499
9506
  } catch (error) {
@@ -8504,62 +9511,64 @@ class Finder {
8504
9511
  if (gen !== this.gen || this.signal.aborted) {
8505
9512
  return;
8506
9513
  }
8507
- this.view.error = reason(this.site, error);
8508
- this.view.searching = false;
9514
+ this.board.error = reason(this.site, error);
9515
+ this.board.searching = false;
9516
+ this.view.error = this.board.error;
9517
+ this.show();
8509
9518
  this.draw();
8510
9519
  }
8511
9520
  wants() {
8512
- const view = this.view;
8513
- if (view.checked < Math.min(view.total, PAGE)) {
8514
- return true;
8515
- }
8516
9521
  const { perRow, rowsVis } = gridShape(this.cols, this.rows);
8517
- return view.tiles.length < (view.top + rowsVis + LOOKAHEAD) * perRow;
9522
+ return this.view.tiles.length < (this.board.top + rowsVis + LOOKAHEAD) * perRow;
8518
9523
  }
8519
9524
  async pump() {
8520
9525
  const gen = this.gen;
8521
- if (this.pumping === gen || this.sources.length === 0) {
9526
+ const board = this.board;
9527
+ if (this.pumping === gen || board.sources.length === 0) {
8522
9528
  return;
8523
9529
  }
8524
9530
  this.pumping = gen;
8525
- const view = this.view;
9531
+ const site = this.site;
8526
9532
  try {
8527
- view.searching = true;
9533
+ board.searching = true;
8528
9534
  while (gen === this.gen && this.wants()) {
8529
- if (this.queue.length === 0) {
8530
- const source = this.sources.find((s) => !s.done);
9535
+ if (board.queue.length === 0) {
9536
+ const source = board.sources.find((s) => !s.done);
8531
9537
  if (!source) {
8532
9538
  break;
8533
9539
  }
8534
- const posts = await fetchPosts(this.site, source.tags, source.page, this.signal);
9540
+ const posts = await fetchPosts(site, source.tags, source.page, this.signal);
8535
9541
  if (gen !== this.gen) {
8536
9542
  return;
8537
9543
  }
8538
9544
  source.page++;
8539
9545
  source.done = posts.length < PAGE;
8540
9546
  for (const post2 of posts) {
8541
- if (!this.seen.has(post2.id)) {
8542
- this.seen.add(post2.id);
8543
- this.queue.push(post2);
9547
+ if (!board.seen.has(post2.id)) {
9548
+ board.seen.add(post2.id);
9549
+ board.queue.push(post2);
8544
9550
  }
8545
9551
  }
8546
9552
  continue;
8547
9553
  }
8548
- const batch = this.queue.splice(0, WORKERS);
8549
- const passed = await Promise.all(batch.map((post2) => this.passes(post2)));
9554
+ const batch = board.queue.splice(0, WORKERS);
9555
+ const passed = await Promise.all(batch.map((post2) => this.passes(site, post2)));
9556
+ this.saveProbes(site);
8550
9557
  if (gen !== this.gen) {
8551
9558
  return;
8552
9559
  }
8553
9560
  batch.forEach((post2, i) => {
8554
- view.checked++;
9561
+ board.checked++;
8555
9562
  if (passed[i]) {
8556
- this.admit(post2);
9563
+ this.admit(site, post2);
8557
9564
  }
8558
9565
  });
9566
+ this.show();
8559
9567
  this.draw();
8560
9568
  }
8561
9569
  if (gen === this.gen) {
8562
- view.searching = false;
9570
+ board.searching = false;
9571
+ this.show();
8563
9572
  this.draw();
8564
9573
  }
8565
9574
  } catch (error) {
@@ -8570,37 +9579,62 @@ class Finder {
8570
9579
  }
8571
9580
  }
8572
9581
  }
8573
- async passes(post2) {
8574
- if (!safe(post2)) {
9582
+ async passes(site, post2) {
9583
+ const version2 = rendition(post2);
9584
+ if (!rated(site, post2, this.view.rating) || exposed(post2, this.view.block).length > 0 || !version2) {
8575
9585
  return false;
8576
9586
  }
8577
9587
  if (this.view.preset === "all") {
8578
- return ["png", "jpg", "jpeg"].includes(post2.ext);
9588
+ return ["png", "jpg", "jpeg"].includes(version2.ext);
8579
9589
  }
8580
9590
  if (post2.ext !== "png") {
8581
9591
  return false;
8582
9592
  }
8583
- if (this.site.vouched) {
8584
- return true;
9593
+ return site.vouched || this.transparent(site, post2);
9594
+ }
9595
+ probes(site) {
9596
+ let known = this.probed.get(site.key);
9597
+ if (!known) {
9598
+ known = readCache(site, "probes.json");
9599
+ this.probed.set(site.key, known);
9600
+ }
9601
+ return known;
9602
+ }
9603
+ async transparent(site, post2) {
9604
+ const known = this.probes(site);
9605
+ const cached = known[post2.id];
9606
+ if (cached !== undefined) {
9607
+ return cached;
8585
9608
  }
8586
9609
  try {
8587
- return (await headOf(this.site, post2.file, this.signal))?.alpha === true;
9610
+ const alpha = (await headOf(site, post2.file, this.signal))?.alpha === true;
9611
+ known[post2.id] = alpha;
9612
+ this.unsaved.add(site.key);
9613
+ return alpha;
8588
9614
  } catch {
8589
9615
  return false;
8590
9616
  }
8591
9617
  }
8592
- admit(post2) {
8593
- const tile = {
8594
- id: post2.id,
8595
- width: post2.width,
8596
- height: post2.height,
8597
- owner: post2.owner,
8598
- origin: originHost(post2.source),
8599
- mates: this.owners.get(this.site.key)?.get(post2.owner) ?? []
8600
- };
8601
- this.posts.set(post2.id, post2);
8602
- this.view.tiles.push(tile);
8603
- this.thumbQueue.push(tile);
9618
+ saveProbes(site) {
9619
+ if (this.unsaved.delete(site.key)) {
9620
+ writeCache(site, "probes.json", this.probes(site));
9621
+ }
9622
+ }
9623
+ admit(site, post2) {
9624
+ this.posts.set(this.mark(site, post2.id), post2);
9625
+ const last = this.view.sets === "fold" ? this.board.groups.at(-1) : undefined;
9626
+ const head = last?.posts[0];
9627
+ const credit = post2.owner || post2.artist;
9628
+ if (last && head && credit && (head.owner || head.artist) === credit && head.width === post2.width && head.height === post2.height) {
9629
+ last.posts.push(post2);
9630
+ if (last.open) {
9631
+ this.thumbQueue.push({ site, post: post2 });
9632
+ this.thumbs();
9633
+ }
9634
+ return;
9635
+ }
9636
+ this.board.groups.push({ posts: [post2], open: false });
9637
+ this.thumbQueue.push({ site, post: post2 });
8604
9638
  this.thumbs();
8605
9639
  }
8606
9640
  async mateOwners(site) {
@@ -8609,11 +9643,7 @@ class Finder {
8609
9643
  return cached;
8610
9644
  }
8611
9645
  const found = origins(this.home);
8612
- const cachePath = join7(cacheDir(site), "owners.json");
8613
- let known = {};
8614
- try {
8615
- known = JSON.parse(readFileSync4(cachePath, "utf8"));
8616
- } catch {}
9646
+ const known = readCache(site, "owners.json");
8617
9647
  const byPalette = new Map;
8618
9648
  for (const sibling of this.catalog.palettes) {
8619
9649
  const origin = found.get(sibling.name);
@@ -8631,48 +9661,54 @@ class Finder {
8631
9661
  }
8632
9662
  byPalette.set(sibling.name, owner);
8633
9663
  }
8634
- mkdirSync4(dirname3(cachePath), { recursive: true });
8635
- writeFileSync4(cachePath, `${JSON.stringify(known)}
8636
- `);
9664
+ writeCache(site, "owners.json", known);
8637
9665
  const owners = mates(byPalette);
8638
9666
  this.owners.set(site.key, owners);
8639
9667
  return owners;
8640
9668
  }
8641
- async cached(site, path2, url) {
9669
+ async cached(site, path2, url, progress2) {
8642
9670
  if (existsSync3(path2)) {
8643
- return new Uint8Array(readFileSync4(path2));
9671
+ return new Uint8Array(readFileSync5(path2));
9672
+ }
9673
+ const running = this.inflight.get(path2);
9674
+ if (running) {
9675
+ return running;
9676
+ }
9677
+ const job = (async () => {
9678
+ const bytes = await fetchBytes(site, url, this.signal, progress2);
9679
+ mkdirSync4(dirname3(path2), { recursive: true });
9680
+ writeFileSync4(path2, bytes);
9681
+ return bytes;
9682
+ })();
9683
+ this.inflight.set(path2, job);
9684
+ try {
9685
+ return await job;
9686
+ } finally {
9687
+ this.inflight.delete(path2);
8644
9688
  }
8645
- const bytes = await fetchBytes(site, url, this.signal);
8646
- mkdirSync4(dirname3(path2), { recursive: true });
8647
- writeFileSync4(path2, bytes);
8648
- return bytes;
8649
9689
  }
8650
9690
  thumbs() {
8651
9691
  while (this.thumbing < WORKERS && this.thumbQueue.length > 0) {
8652
- const tile = this.thumbQueue.shift();
9692
+ const { site, post: post2 } = this.thumbQueue.shift();
8653
9693
  this.thumbing++;
8654
- this.thumb(tile).catch(() => {}).finally(() => {
9694
+ this.thumb(site, post2).catch(() => {}).finally(() => {
8655
9695
  this.thumbing--;
8656
9696
  this.thumbs();
8657
9697
  });
8658
9698
  }
8659
9699
  }
8660
- async thumb(tile) {
8661
- const post2 = this.posts.get(tile.id);
8662
- const site = this.site;
8663
- if (!post2) {
8664
- return;
8665
- }
9700
+ async thumb(site, post2) {
8666
9701
  const w = TILE.cols * this.cell.w;
8667
9702
  const h = TILE.rows * this.cell.h;
8668
- const path2 = join7(cacheDir(site), "tile", `${tile.id}-${w}x${h}.png`);
9703
+ const path2 = join7(this.scratch, "tile", `${site.key}-${post2.id}-${w}x${h}.png`);
8669
9704
  if (!existsSync3(path2)) {
8670
- const thumb = join7(cacheDir(site), "thumb", `${tile.id}.${extension(post2.preview)}`);
9705
+ const thumb = join7(cacheDir(site), "thumb", `${post2.id}.${extension(post2.preview)}`);
8671
9706
  const bytes = await this.cached(site, thumb, post2.preview);
8672
9707
  mkdirSync4(dirname3(path2), { recursive: true });
8673
- writeFileSync4(path2, encodePng(contain(decodeImage(bytes), w, h)));
9708
+ writeFileSync4(path2, encodePng(contain(decodeImage(bytes, MAX_PIXELS), w, h)));
8674
9709
  }
8675
- tile.thumb = path2;
9710
+ this.thumbPath.set(this.mark(site, post2.id), path2);
9711
+ this.show();
8676
9712
  this.draw();
8677
9713
  }
8678
9714
  select() {
@@ -8694,31 +9730,26 @@ class Finder {
8694
9730
  }
8695
9731
  async load(tile) {
8696
9732
  const view = this.view;
8697
- const post2 = this.posts.get(tile.id);
8698
- if (!post2) {
9733
+ const post2 = this.posts.get(this.mark(this.site, tile.id));
9734
+ const version2 = post2 && rendition(post2);
9735
+ if (!version2) {
8699
9736
  return;
8700
9737
  }
8701
9738
  const site = this.site;
8702
9739
  const control = new AbortController;
8703
9740
  this.fetch = control;
8704
- const signal = AbortSignal.any([this.signal, control.signal]);
8705
9741
  try {
8706
- const path2 = join7(cacheDir(site), "orig", `${tile.id}.${post2.ext}`);
8707
- let bytes;
8708
- if (existsSync3(path2)) {
8709
- bytes = new Uint8Array(readFileSync4(path2));
8710
- } else {
9742
+ const path2 = this.origPath(site, tile.id, version2.ext);
9743
+ if (!existsSync3(path2)) {
8711
9744
  view.fetching = { id: tile.id, got: 0, size: 0 };
8712
9745
  this.draw();
8713
- bytes = await fetchBytes(site, post2.file, signal, (got, size) => {
8714
- if (view.fetching?.id === tile.id) {
8715
- view.fetching = { id: tile.id, got, size };
8716
- this.draw();
8717
- }
8718
- });
8719
- mkdirSync4(dirname3(path2), { recursive: true });
8720
- writeFileSync4(path2, bytes);
8721
9746
  }
9747
+ const bytes = await this.cached(site, path2, version2.file, (got, size) => {
9748
+ if (view.fetching?.id === tile.id) {
9749
+ view.fetching = { id: tile.id, got, size };
9750
+ this.draw();
9751
+ }
9752
+ });
8722
9753
  if (control.signal.aborted) {
8723
9754
  return;
8724
9755
  }
@@ -8726,14 +9757,41 @@ class Finder {
8726
9757
  view.preparing = tile.id;
8727
9758
  this.flush();
8728
9759
  await tick();
8729
- const image = decodeImage(bytes);
9760
+ const image = decodeImage(bytes, MAX_PIXELS);
8730
9761
  if (site !== this.site) {
8731
9762
  return;
8732
9763
  }
8733
- this.current = { site, id: tile.id, image, bytes, ext: post2.ext, clear: transparency(image) };
9764
+ const current = {
9765
+ site,
9766
+ id: tile.id,
9767
+ image,
9768
+ plain: image,
9769
+ failed: false,
9770
+ bytes,
9771
+ ext: version2.ext,
9772
+ clear: transparency(image)
9773
+ };
9774
+ if (current.clear === 0 && canRemoveBackground() && this.setting("TTHEME_FIND_REMOVE_BG") !== "off") {
9775
+ view.preparing = undefined;
9776
+ view.cutting = tile.id;
9777
+ this.flush();
9778
+ const cut = await this.cutOut(site, tile.id, path2, control.signal);
9779
+ if (control.signal.aborted || site !== this.site) {
9780
+ return;
9781
+ }
9782
+ if (cut) {
9783
+ current.cut = cut;
9784
+ current.image = cut;
9785
+ current.clear = transparency(cut);
9786
+ } else {
9787
+ current.failed = true;
9788
+ }
9789
+ }
9790
+ this.current = current;
8734
9791
  if (view.tiles[view.focus]?.id === tile.id) {
8735
9792
  this.present();
8736
9793
  }
9794
+ this.preload();
8737
9795
  } catch (error) {
8738
9796
  if (!control.signal.aborted && !this.signal.aborted) {
8739
9797
  view.error = reason(site, error);
@@ -8745,9 +9803,59 @@ class Finder {
8745
9803
  if (view.preparing === tile.id) {
8746
9804
  view.preparing = undefined;
8747
9805
  }
9806
+ if (view.cutting === tile.id) {
9807
+ view.cutting = undefined;
9808
+ }
8748
9809
  this.draw();
8749
9810
  }
8750
9811
  }
9812
+ async cutOut(site, id, path2, signal) {
9813
+ const out = join7(this.scratch, "cut", `${site.key}-${id}.png`);
9814
+ try {
9815
+ mkdirSync4(dirname3(out), { recursive: true });
9816
+ const cut = existsSync3(out) ? decodeImage(new Uint8Array(readFileSync5(out)), MAX_PIXELS) : await removeBackground(path2, out, signal);
9817
+ return keepable(transparency(cut)) ? cut : undefined;
9818
+ } catch {
9819
+ rmSync4(out, { force: true });
9820
+ return;
9821
+ }
9822
+ }
9823
+ swap() {
9824
+ const current = this.current;
9825
+ if (!current?.cut || this.view.shown?.id !== current.id) {
9826
+ return;
9827
+ }
9828
+ current.image = current.image === current.cut ? current.plain : current.cut;
9829
+ current.clear = transparency(current.image);
9830
+ this.present();
9831
+ this.draw();
9832
+ }
9833
+ preload() {
9834
+ const view = this.view;
9835
+ if (view.mode !== "try") {
9836
+ return;
9837
+ }
9838
+ const site = this.site;
9839
+ for (const index of [view.focus + this.direction, view.focus - this.direction]) {
9840
+ if (this.prefetching >= PRELOAD) {
9841
+ return;
9842
+ }
9843
+ const tile = view.tiles[index];
9844
+ const post2 = tile && this.posts.get(this.mark(site, tile.id));
9845
+ const version2 = post2 && rendition(post2);
9846
+ if (!version2) {
9847
+ continue;
9848
+ }
9849
+ const path2 = this.origPath(site, post2.id, version2.ext);
9850
+ if (existsSync3(path2)) {
9851
+ continue;
9852
+ }
9853
+ this.prefetching++;
9854
+ this.cached(site, path2, version2.file).catch(() => {}).finally(() => {
9855
+ this.prefetching--;
9856
+ });
9857
+ }
9858
+ }
8751
9859
  present() {
8752
9860
  const current = this.current;
8753
9861
  if (!current) {
@@ -8757,11 +9865,12 @@ class Finder {
8757
9865
  const H = this.rows * this.cell.h;
8758
9866
  const width2 = Math.min(W, TRY_WIDTH);
8759
9867
  const height = Math.max(1, Math.round(H * width2 / W));
8760
- const path2 = join7(this.scratch, `${current.id}-${width2}x${height}.png`);
9868
+ const cut = current.cut ? current.image === current.cut ? "on" : "off" : current.failed ? "failed" : "none";
9869
+ const path2 = join7(this.scratch, `${current.id}${cut === "on" ? "c" : ""}-${width2}x${height}.png`);
8761
9870
  if (!existsSync3(path2)) {
8762
9871
  writeFileSync4(path2, encodePng(tryOn(current.image, this.entry, this.tone, width2, height)));
8763
9872
  }
8764
- this.view.shown = { id: current.id, clear: current.clear, bytes: current.bytes.length, path: path2 };
9873
+ this.view.shown = { id: current.id, clear: current.clear, bytes: current.bytes.length, path: path2, cut };
8765
9874
  }
8766
9875
  async install() {
8767
9876
  const view = this.view;
@@ -8831,17 +9940,45 @@ class Finder {
8831
9940
  this.write(`${out}\x1B[?2026l`);
8832
9941
  }
8833
9942
  }
9943
+ function routable() {
9944
+ if (process.versions.bun) {
9945
+ return true;
9946
+ }
9947
+ const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
9948
+ return major > 22 || major === 22 && minor >= 21;
9949
+ }
9950
+ async function relaunch() {
9951
+ if (!routable()) {
9952
+ process.stderr.write(`unblock needs node 22.21 or newer — this is ${process.versions.node}
9953
+ `);
9954
+ return 1;
9955
+ }
9956
+ const proxy = await tunnel();
9957
+ const child = spawn2(process.execPath, process.argv.slice(1), {
9958
+ stdio: "inherit",
9959
+ env: {
9960
+ ...process.env,
9961
+ NODE_USE_ENV_PROXY: "1",
9962
+ NODE_NO_WARNINGS: "1",
9963
+ HTTPS_PROXY: `http://127.0.0.1:${proxy.port}`,
9964
+ TTHEME_FIND_PROXY: String(proxy.port)
9965
+ }
9966
+ });
9967
+ const code = await new Promise((resolve2) => child.on("exit", (status2) => resolve2(status2 ?? 1)));
9968
+ proxy.close();
9969
+ return code;
9970
+ }
8834
9971
  async function runFind(name) {
9972
+ if (process.env.TTHEME_FIND_UNBLOCK === "1" && !process.env.TTHEME_FIND_PROXY) {
9973
+ return relaunch();
9974
+ }
8835
9975
  const home = configHome();
8836
9976
  const catalog = readCatalog(home);
8837
9977
  const entry = find2(catalog.palettes, name);
8838
- if (!entry.booru) {
8839
- throw new Error(`${name} has no booru tag to search for`);
8840
- }
8841
9978
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
8842
9979
  throw new Error("ttheme find needs a terminal");
8843
9980
  }
8844
- return new Finder(home, catalog, entry, entry.booru).run();
9981
+ return new Finder(home, catalog, entry, entry.booru ?? "").run();
8845
9982
  }
8846
9983
 
8847
9984
  // src/init.ts
@@ -8851,7 +9988,7 @@ import {
8851
9988
  existsSync as existsSync5,
8852
9989
  mkdirSync as mkdirSync5,
8853
9990
  readdirSync as readdirSync3,
8854
- readFileSync as readFileSync5,
9991
+ readFileSync as readFileSync6,
8855
9992
  rmSync as rmSync5,
8856
9993
  writeFileSync as writeFileSync5
8857
9994
  } from "node:fs";
@@ -9970,34 +11107,6 @@ class a extends V {
9970
11107
  });
9971
11108
  }
9972
11109
  }
9973
- var n$1 = class n extends V {
9974
- options;
9975
- cursor = 0;
9976
- get _selectedValue() {
9977
- return this.options[this.cursor];
9978
- }
9979
- changeValue() {
9980
- const e = this._selectedValue;
9981
- this.value = e === undefined ? undefined : e.value;
9982
- }
9983
- constructor(e) {
9984
- super(e, false), this.options = e.options;
9985
- const o2 = this.options.findIndex(({ value: s }) => s === e.initialValue), t2 = o2 === -1 ? 0 : o2;
9986
- this.cursor = this.options[t2]?.disabled ? findCursor(t2, 1, this.options) : t2, this.changeValue(), this.on("cursor", (s) => {
9987
- switch (s) {
9988
- case "left":
9989
- case "up":
9990
- this.cursor = findCursor(this.cursor, -1, this.options);
9991
- break;
9992
- case "down":
9993
- case "right":
9994
- this.cursor = findCursor(this.cursor, 1, this.options);
9995
- break;
9996
- }
9997
- this.changeValue();
9998
- });
9999
- }
10000
- };
10001
11110
 
10002
11111
  // node_modules/@clack/prompts/dist/index.mjs
10003
11112
  import { styleText as styleText2, stripVTControlCharacters as stripVTControlCharacters3 } from "node:util";
@@ -10100,7 +11209,7 @@ var limitOptions = ({
10100
11209
  d && g2++, c2 && g2++;
10101
11210
  const T3 = f2 + (d ? 1 : 0), y = W - (c2 ? 1 : 0);
10102
11211
  for (let t2 = T3;t2 < y; t2++) {
10103
- const n3 = e[t2], o2 = n3 ? w(n3, t2 === l) : "", h2 = wrapAnsi(o2, i, {
11212
+ const n2 = e[t2], o2 = n2 ? w(n2, t2 === l) : "", h2 = wrapAnsi(o2, i, {
10104
11213
  hard: true,
10105
11214
  trim: false
10106
11215
  }).split(`
@@ -10108,17 +11217,17 @@ var limitOptions = ({
10108
11217
  s.push(h2), g2 += h2.length;
10109
11218
  }
10110
11219
  if (g2 > v) {
10111
- let t2 = 0, n3 = 0, o2 = g2;
11220
+ let t2 = 0, n2 = 0, o2 = g2;
10112
11221
  const h2 = l - T3;
10113
11222
  let u3 = v;
10114
11223
  const L = () => I(s, o2, 0, h2, u3), E = () => I(s, o2, h2 + 1, s.length, u3, true);
10115
- d ? ({ lineCount: o2, removals: t2 } = L(), o2 > u3 && (c2 || (u3 -= 1), { lineCount: o2, removals: n3 } = E())) : (c2 || (u3 -= 1), { lineCount: o2, removals: n3 } = E(), o2 > u3 && (u3 -= 1, { lineCount: o2, removals: t2 } = L())), t2 > 0 && (d = true, s.splice(0, t2)), n3 > 0 && (c2 = true, s.splice(s.length - n3, n3));
11224
+ d ? ({ lineCount: o2, removals: t2 } = L(), o2 > u3 && (c2 || (u3 -= 1), { lineCount: o2, removals: n2 } = E())) : (c2 || (u3 -= 1), { lineCount: o2, removals: n2 } = E(), o2 > u3 && (u3 -= 1, { lineCount: o2, removals: t2 } = L())), t2 > 0 && (d = true, s.splice(0, t2)), n2 > 0 && (c2 = true, s.splice(s.length - n2, n2));
10116
11225
  }
10117
11226
  const x = [];
10118
11227
  d && x.push(M2);
10119
11228
  for (const t2 of s)
10120
- for (const n3 of t2)
10121
- x.push(n3);
11229
+ for (const n2 of t2)
11230
+ x.push(n2);
10122
11231
  return c2 && x.push(M2), x;
10123
11232
  };
10124
11233
  var confirm = (i) => {
@@ -10185,58 +11294,58 @@ ${styleText2("reset", styleText2("dim", `Press ${styleText2(["gray", "bgWhite",
10185
11294
  render() {
10186
11295
  const t2 = i.withGuide ?? settings.withGuide, a2 = wrapTextWithPrefix(i.output, i.message, t2 ? `${symbolBar(this.state)} ` : "", `${symbol(this.state)} `), r2 = `${t2 ? `${styleText2("gray", S_BAR)}
10187
11296
  ` : ""}${a2}
10188
- `, o2 = this.value ?? [], p2 = (n3, l) => {
10189
- if (n3.disabled)
10190
- return u3(n3, "disabled");
10191
- const s = o2.includes(n3.value);
10192
- return l && s ? u3(n3, "active-selected") : s ? u3(n3, "selected") : u3(n3, l ? "active" : "inactive");
11297
+ `, o2 = this.value ?? [], p2 = (n2, l) => {
11298
+ if (n2.disabled)
11299
+ return u3(n2, "disabled");
11300
+ const s = o2.includes(n2.value);
11301
+ return l && s ? u3(n2, "active-selected") : s ? u3(n2, "selected") : u3(n2, l ? "active" : "inactive");
10193
11302
  };
10194
11303
  switch (this.state) {
10195
11304
  case "submit": {
10196
- const n3 = this.options.filter(({ value: s }) => o2.includes(s)).map((s) => u3(s, "submitted")).join(styleText2("dim", ", ")) || styleText2("dim", "none"), l = wrapTextWithPrefix(i.output, n3, t2 ? `${styleText2("gray", S_BAR)} ` : "");
11305
+ const n2 = this.options.filter(({ value: s }) => o2.includes(s)).map((s) => u3(s, "submitted")).join(styleText2("dim", ", ")) || styleText2("dim", "none"), l = wrapTextWithPrefix(i.output, n2, t2 ? `${styleText2("gray", S_BAR)} ` : "");
10197
11306
  return `${r2}${l}`;
10198
11307
  }
10199
11308
  case "cancel": {
10200
- const n3 = this.options.filter(({ value: s }) => o2.includes(s)).map((s) => u3(s, "cancelled")).join(styleText2("dim", ", "));
10201
- if (n3.trim() === "")
11309
+ const n2 = this.options.filter(({ value: s }) => o2.includes(s)).map((s) => u3(s, "cancelled")).join(styleText2("dim", ", "));
11310
+ if (n2.trim() === "")
10202
11311
  return `${r2}${styleText2("gray", S_BAR)}`;
10203
- const l = wrapTextWithPrefix(i.output, n3, t2 ? `${styleText2("gray", S_BAR)} ` : "");
11312
+ const l = wrapTextWithPrefix(i.output, n2, t2 ? `${styleText2("gray", S_BAR)} ` : "");
10204
11313
  return `${r2}${l}${t2 ? `
10205
11314
  ${styleText2("gray", S_BAR)}` : ""}`;
10206
11315
  }
10207
11316
  case "error": {
10208
- const n3 = t2 ? `${styleText2("yellow", S_BAR)} ` : "", l = this.error.split(`
11317
+ const n2 = t2 ? `${styleText2("yellow", S_BAR)} ` : "", l = this.error.split(`
10209
11318
  `).map(($, C2) => C2 === 0 ? `${t2 ? `${styleText2("yellow", S_BAR_END)} ` : ""}${styleText2("yellow", $)}` : ` ${$}`).join(`
10210
11319
  `), s = r2.split(`
10211
11320
  `).length, h2 = l.split(`
10212
11321
  `).length + 1;
10213
- return `${r2}${n3}${limitOptions({
11322
+ return `${r2}${n2}${limitOptions({
10214
11323
  output: i.output,
10215
11324
  options: this.options,
10216
11325
  cursor: this.cursor,
10217
11326
  maxItems: i.maxItems,
10218
- columnPadding: n3.length,
11327
+ columnPadding: n2.length,
10219
11328
  rowPadding: s + h2,
10220
11329
  style: p2
10221
11330
  }).join(`
10222
- ${n3}`)}
11331
+ ${n2}`)}
10223
11332
  ${l}
10224
11333
  `;
10225
11334
  }
10226
11335
  default: {
10227
- const n3 = t2 ? `${styleText2("cyan", S_BAR)} ` : "", l = r2.split(`
11336
+ const n2 = t2 ? `${styleText2("cyan", S_BAR)} ` : "", l = r2.split(`
10228
11337
  `).length, s = v ? formatInstructionFooter(MULTISELECT_INSTRUCTIONS, t2) : t2 ? [styleText2("cyan", S_BAR_END)] : [], h2 = s.join(`
10229
11338
  `), $ = s.length + 1;
10230
- return `${r2}${n3}${limitOptions({
11339
+ return `${r2}${n2}${limitOptions({
10231
11340
  output: i.output,
10232
11341
  options: this.options,
10233
11342
  cursor: this.cursor,
10234
11343
  maxItems: i.maxItems,
10235
- columnPadding: n3.length,
11344
+ columnPadding: n2.length,
10236
11345
  rowPadding: l + $,
10237
11346
  style: p2
10238
11347
  }).join(`
10239
- ${n3}`)}
11348
+ ${n2}`)}
10240
11349
  ${h2}
10241
11350
  `;
10242
11351
  }
@@ -10268,18 +11377,18 @@ var C2 = (o2, e, s) => {
10268
11377
  hard: true,
10269
11378
  trim: false
10270
11379
  }, i = wrapAnsi(o2, e, a2).split(`
10271
- `), c2 = i.reduce((n3, t2) => Math.max(dist_default2(t2), n3), 0), u3 = i.map(s).reduce((n3, t2) => Math.max(dist_default2(t2), n3), 0), g2 = e - (u3 - c2);
11380
+ `), c2 = i.reduce((n2, t2) => Math.max(dist_default2(t2), n2), 0), u3 = i.map(s).reduce((n2, t2) => Math.max(dist_default2(t2), n2), 0), g2 = e - (u3 - c2);
10272
11381
  return wrapAnsi(o2, g2, a2);
10273
11382
  };
10274
11383
  var note = (o2 = "", e = "", s) => {
10275
11384
  const a2 = s?.output ?? process$1.stdout, i = s?.withGuide ?? settings.withGuide, c2 = s?.format ?? W$1, g2 = ["", ...C2(o2, getColumns(a2) - 6, c2).split(`
10276
- `).map(c2), ""], n3 = dist_default2(e), t2 = Math.max(g2.reduce((m3, F) => {
11385
+ `).map(c2), ""], n2 = dist_default2(e), t2 = Math.max(g2.reduce((m3, F) => {
10277
11386
  const O = dist_default2(F);
10278
11387
  return O > m3 ? O : m3;
10279
- }, 0), n3) + 2, h2 = g2.map((m3) => `${styleText2("gray", S_BAR)} ${m3}${" ".repeat(t2 - dist_default2(m3))}${styleText2("gray", S_BAR)}`).join(`
11388
+ }, 0), n2) + 2, h2 = g2.map((m3) => `${styleText2("gray", S_BAR)} ${m3}${" ".repeat(t2 - dist_default2(m3))}${styleText2("gray", S_BAR)}`).join(`
10280
11389
  `), T3 = i ? `${styleText2("gray", S_BAR)}
10281
11390
  ` : "", l$1 = i ? S_CONNECT_LEFT : S_CORNER_BOTTOM_LEFT;
10282
- a2.write(`${T3}${styleText2("green", S_STEP_SUBMIT)} ${styleText2("reset", e)} ${styleText2("gray", S_BAR_H.repeat(Math.max(t2 - n3 - 1, 1)) + S_CORNER_TOP_RIGHT)}
11391
+ a2.write(`${T3}${styleText2("green", S_STEP_SUBMIT)} ${styleText2("reset", e)} ${styleText2("gray", S_BAR_H.repeat(Math.max(t2 - n2 - 1, 1)) + S_CORNER_TOP_RIGHT)}
10283
11392
  ${h2}
10284
11393
  ${styleText2("gray", l$1 + S_BAR_H.repeat(t2 + 2) + S_CORNER_BOTTOM_RIGHT)}
10285
11394
  `);
@@ -10293,69 +11402,6 @@ var SELECT_INSTRUCTIONS = [
10293
11402
  `${styleText2("dim", "↑/↓")} to navigate`,
10294
11403
  `${styleText2("dim", "Enter:")} confirm`
10295
11404
  ];
10296
- var c2 = (t2, o2) => t2.includes(`
10297
- `) ? t2.split(`
10298
- `).map((d) => o2(d)).join(`
10299
- `) : o2(t2);
10300
- var select = (t2) => {
10301
- const o2 = (n3, m3) => {
10302
- if (n3 === undefined)
10303
- return "";
10304
- const s = n3.label ?? String(n3.value);
10305
- switch (m3) {
10306
- case "disabled":
10307
- return `${styleText2("gray", S_RADIO_INACTIVE)} ${c2(s, (i) => styleText2("gray", i))}${n3.hint ? ` ${styleText2("dim", `(${n3.hint ?? "disabled"})`)}` : ""}`;
10308
- case "selected":
10309
- return `${c2(s, (i) => styleText2("dim", i))}`;
10310
- case "active":
10311
- return `${styleText2("green", S_RADIO_ACTIVE)} ${s}${n3.hint ? ` ${styleText2("dim", `(${n3.hint})`)}` : ""}`;
10312
- case "cancelled":
10313
- return `${c2(s, (i) => styleText2(["strikethrough", "dim"], i))}`;
10314
- default:
10315
- return `${styleText2("dim", S_RADIO_INACTIVE)} ${c2(s, (i) => styleText2("dim", i))}`;
10316
- }
10317
- }, d = t2.showInstructions ?? true;
10318
- return new n$1({
10319
- options: t2.options,
10320
- signal: t2.signal,
10321
- input: t2.input,
10322
- output: t2.output,
10323
- initialValue: t2.initialValue,
10324
- render() {
10325
- const n3 = t2.withGuide ?? settings.withGuide, m3 = `${symbol(this.state)} `, s = `${symbolBar(this.state)} `, i = wrapTextWithPrefix(t2.output, t2.message, s, m3), u4 = `${n3 ? `${styleText2("gray", S_BAR)}
10326
- ` : ""}${i}
10327
- `;
10328
- switch (this.state) {
10329
- case "submit": {
10330
- const r2 = n3 ? `${styleText2("gray", S_BAR)} ` : "", a2 = wrapTextWithPrefix(t2.output, o2(this.options[this.cursor], "selected"), r2);
10331
- return `${u4}${a2}`;
10332
- }
10333
- case "cancel": {
10334
- const r2 = n3 ? `${styleText2("gray", S_BAR)} ` : "", a2 = wrapTextWithPrefix(t2.output, o2(this.options[this.cursor], "cancelled"), r2);
10335
- return `${u4}${a2}${n3 ? `
10336
- ${styleText2("gray", S_BAR)}` : ""}`;
10337
- }
10338
- default: {
10339
- const r2 = n3 ? `${styleText2("cyan", S_BAR)} ` : "", a2 = u4.split(`
10340
- `).length, p2 = d ? formatInstructionFooter(SELECT_INSTRUCTIONS, n3) : n3 ? [styleText2("cyan", S_BAR_END)] : [], b2 = p2.join(`
10341
- `), f2 = p2.length + 1;
10342
- return `${u4}${r2}${limitOptions({
10343
- output: t2.output,
10344
- cursor: this.cursor,
10345
- options: this.options,
10346
- maxItems: t2.maxItems,
10347
- columnPadding: r2.length,
10348
- rowPadding: a2 + f2,
10349
- style: (g2, x) => o2(g2, g2.disabled ? "disabled" : x ? "active" : "inactive")
10350
- }).join(`
10351
- ${r2}`)}
10352
- ${b2}
10353
- `;
10354
- }
10355
- }
10356
- }
10357
- }).prompt();
10358
- };
10359
11405
  var i = `${styleText2("gray", S_BAR)} `;
10360
11406
 
10361
11407
  // src/market.ts
@@ -10366,7 +11412,7 @@ import { openSync, writeSync } from "node:fs";
10366
11412
  import { ReadStream } from "node:tty";
10367
11413
  var QUERY_CODES = ["10", "11", "12", "17", ...Array.from({ length: 16 }, (_2, i2) => `4;${i2}`)];
10368
11414
  function paletteOsc(entry) {
10369
- const osc4 = entry.ansi.map((c3, i2) => `;${i2};${c3}`).join("");
11415
+ const osc4 = entry.ansi.map((c2, i2) => `;${i2};${c2}`).join("");
10370
11416
  return [
10371
11417
  `\x1B]11;${entry.background}\x1B\\`,
10372
11418
  `\x1B]10;${entry.foreground}\x1B\\`,
@@ -10422,7 +11468,7 @@ async function queryTerminalColors() {
10422
11468
  // src/ansi.ts
10423
11469
  function rgb2(hex2) {
10424
11470
  const h2 = hex2.replace("#", "");
10425
- return [h2.slice(0, 2), h2.slice(2, 4), h2.slice(4, 6)].map((c3) => Number.parseInt(c3, 16)).join(";");
11471
+ return [h2.slice(0, 2), h2.slice(2, 4), h2.slice(4, 6)].map((c2) => Number.parseInt(c2, 16)).join(";");
10426
11472
  }
10427
11473
  function ansiChip(text, bg2) {
10428
11474
  return `\x1B[48;2;${rgb2(bg2)}m ${text} \x1B[0m`;
@@ -10434,7 +11480,7 @@ function ansiFg(color2) {
10434
11480
  return `\x1B[38;2;${rgb2(color2)}m`;
10435
11481
  }
10436
11482
  function ansiSwatch(colors, bg2) {
10437
- return `\x1B[48;2;${rgb2(bg2)}m${colors.map((c3) => `\x1B[38;2;${rgb2(c3)}m▄`).join("")} \x1B[0m`;
11483
+ return `\x1B[48;2;${rgb2(bg2)}m${colors.map((c2) => `\x1B[38;2;${rgb2(c2)}m▄`).join("")} \x1B[0m`;
10438
11484
  }
10439
11485
 
10440
11486
  // src/palette-prompt.ts
@@ -10485,6 +11531,7 @@ var DIM = "\x1B[2m";
10485
11531
  var BOLD = "\x1B[1m";
10486
11532
  var BOLD_INVERSE = "\x1B[1;7m";
10487
11533
  var CYAN = "\x1B[36m";
11534
+ var YELLOW2 = "\x1B[33m";
10488
11535
  function promptFx(value) {
10489
11536
  return value === "decode" || value === "glitch" ? value : "typewriter";
10490
11537
  }
@@ -10511,7 +11558,12 @@ class PalettePrompt extends V {
10511
11558
  fxSeed = 0;
10512
11559
  fxTimer;
10513
11560
  constructor(opts) {
10514
- super({ render: () => this.draw(), input: opts.input, output: opts.output }, true);
11561
+ super({
11562
+ render: () => this.draw(),
11563
+ input: opts.input,
11564
+ output: opts.output,
11565
+ validate: opts.required ? () => this.picked.size === 0 ? `pick at least one ${this.scope}` : undefined : undefined
11566
+ }, true);
10515
11567
  this.entries = opts.entries;
10516
11568
  this.scope = opts.scope ?? "palette";
10517
11569
  this.picked = new Set(opts.installed ?? []);
@@ -10552,26 +11604,32 @@ class PalettePrompt extends V {
10552
11604
  }
10553
11605
  });
10554
11606
  this.on("key", (_char, key) => {
10555
- if (key?.name === "tab") {
11607
+ if (key?.name === "space") {
10556
11608
  this.pick(this.rows[this.cursor]);
10557
11609
  }
10558
11610
  });
10559
11611
  }
11612
+ _isActionKey(char) {
11613
+ return char === "\t" || char === " ";
11614
+ }
10560
11615
  members(group) {
10561
11616
  const filter = this.scope === "palette" ? this.userInput.trim() : "";
10562
11617
  return this.named.filter((e) => e.group === group && (filter === "" || matchesPalette(e, filter)));
10563
11618
  }
11619
+ everyone(rows) {
11620
+ return rows.flatMap((r2) => r2.kind === "group" ? this.members(r2.name) : []);
11621
+ }
10564
11622
  pick(row) {
10565
- const names = row?.kind === "palette" ? [row.entry.name] : row?.kind === "group" ? this.members(row.name).map((e) => e.name) : [];
11623
+ const names = row?.kind === "palette" ? [row.entry.name] : row?.kind === "group" ? this.members(row.name).map((e) => e.name) : row?.kind === "all" ? this.everyone(this.rows).map((e) => e.name) : [];
10566
11624
  if (names.length === 0) {
10567
11625
  return;
10568
11626
  }
10569
- const add = names.some((n3) => !this.picked.has(n3));
10570
- for (const n3 of names) {
11627
+ const add = names.some((n2) => !this.picked.has(n2));
11628
+ for (const n2 of names) {
10571
11629
  if (add) {
10572
- this.picked.add(n3);
11630
+ this.picked.add(n2);
10573
11631
  } else {
10574
- this.picked.delete(n3);
11632
+ this.picked.delete(n2);
10575
11633
  }
10576
11634
  }
10577
11635
  }
@@ -10625,18 +11683,23 @@ class PalettePrompt extends V {
10625
11683
  }
10626
11684
  rebuild(snap) {
10627
11685
  const focused = this.rows[this.cursor];
10628
- this.rows = this.scope === "series" ? seriesRows(this.entries, this.userInput) : pickerRows(this.entries, this.expanded, this.userInput);
11686
+ const body = this.scope === "series" ? seriesRows(this.entries, this.userInput) : pickerRows(this.entries, this.expanded, this.userInput);
11687
+ const start = body.length > 0 ? firstPalette(body) + 1 : 0;
11688
+ this.rows = body.length > 0 ? [{ kind: "all", count: this.allCount(body) }, ...body] : [];
10629
11689
  if (snap === "first") {
10630
- this.cursor = firstPalette(this.rows);
11690
+ this.cursor = start;
10631
11691
  } else if (snap === "keep") {
10632
11692
  const name = focused?.kind === "palette" ? focused.entry.name : "";
10633
11693
  const kept = name ? this.rows.findIndex((r2) => r2.kind === "palette" && r2.entry.name === name) : -1;
10634
- this.cursor = kept === -1 ? firstPalette(this.rows) : kept;
11694
+ this.cursor = kept === -1 ? start : kept;
10635
11695
  } else if (this.cursor >= this.rows.length) {
10636
11696
  this.cursor = Math.max(0, this.rows.length - 1);
10637
11697
  }
10638
11698
  this.sync();
10639
11699
  }
11700
+ allCount(body) {
11701
+ return this.scope === "series" ? body.length : this.everyone(body).length;
11702
+ }
10640
11703
  move(delta) {
10641
11704
  const next = this.cursor + delta;
10642
11705
  if (next >= 0 && next < this.rows.length) {
@@ -10674,6 +11737,11 @@ class PalettePrompt extends V {
10674
11737
  }
10675
11738
  renderRow(row, focused) {
10676
11739
  const marker = focused ? "▶ " : " ";
11740
+ if (row.kind === "all") {
11741
+ const box2 = this.everyone(this.rows).every((e2) => this.picked.has(e2.name)) ? "● " : "○ ";
11742
+ const tail = `(${row.count})`;
11743
+ return `${marker}${box2}select all ${this.color ? `${DIM}${tail}${RESET}` : tail}`;
11744
+ }
10677
11745
  if (row.kind === "group" && this.scope === "series") {
10678
11746
  const box2 = this.pickedIn(row.name) === row.count ? "● " : "○ ";
10679
11747
  const name = row.name.padEnd(this.seriesPad);
@@ -10714,7 +11782,7 @@ class PalettePrompt extends V {
10714
11782
  }
10715
11783
  const filter = this.userInput.trim();
10716
11784
  const total = this.scope === "series" ? this.series.length : this.named.length;
10717
- const matched = this.scope === "series" ? this.rows.length : filter ? this.named.filter((e) => matchesPalette(e, filter)).length : total;
11785
+ const matched = this.scope === "series" ? this.rows.filter((r2) => r2.kind === "group").length : filter ? this.named.filter((e) => matchesPalette(e, filter)).length : total;
10718
11786
  const head = `${bar("◆")} ${title} ${dim(`(${matched}/${total} · ${this.pickedCount()} picked)`)}`;
10719
11787
  const search2 = `${bar("│")} ${this.userInput ? `⌕ ${this.userInput}_` : dim(`⌕ search…${this.example ? ` e.g. ${this.animatedExample()}` : ""}`)}`;
10720
11788
  if (this.cursor < this.top) {
@@ -10732,7 +11800,8 @@ class PalettePrompt extends V {
10732
11800
  const below = this.rows.length - this.top - window2.length;
10733
11801
  const more = below > 0 ? `${bar("│")} ${dim(`↓ ${below} more`)}` : bar("│");
10734
11802
  const fold = this.scope === "series" ? "" : " · ←→ fold";
10735
- const hint = `${bar("")} ${dim(`↑↓ move${fold} · tab pick · type to filter · enter install · esc cancel`)}`;
11803
+ const go = this.scope === "series" ? "continue" : "install";
11804
+ const hint = this.state === "error" ? `${bar("└")} ${this.color ? `${YELLOW2}${this.error}${RESET}` : this.error}` : `${bar("└")} ${dim(`↑↓ move${fold} · space pick · type to filter · enter ${go} · esc cancel`)}`;
10736
11805
  return [head, search2, ...body, more, hint].join(`
10737
11806
  `);
10738
11807
  }
@@ -10747,8 +11816,8 @@ function runAdd(names) {
10747
11816
  const home = configHome();
10748
11817
  const catalog = readCatalog(home);
10749
11818
  const state = readInstalled(home);
10750
- const already = names.filter((n3) => state.palettes.includes(n3));
10751
- const fresh = names.filter((n3) => !state.palettes.includes(n3));
11819
+ const already = names.filter((n2) => state.palettes.includes(n2));
11820
+ const fresh = names.filter((n2) => !state.palettes.includes(n2));
10752
11821
  if (fresh.length === 0) {
10753
11822
  console.log(`already installed: ${already.join(", ")}`);
10754
11823
  return;
@@ -10765,12 +11834,12 @@ function runRemove(names) {
10765
11834
  const home = configHome();
10766
11835
  const catalog = readCatalog(home);
10767
11836
  const state = readInstalled(home);
10768
- const gone = names.filter((n3) => state.palettes.includes(n3));
11837
+ const gone = names.filter((n2) => state.palettes.includes(n2));
10769
11838
  if (gone.length === 0) {
10770
11839
  console.log(`not installed: ${names.join(", ")}`);
10771
11840
  return;
10772
11841
  }
10773
- const next = { ...state, palettes: state.palettes.filter((n3) => !gone.includes(n3)) };
11842
+ const next = { ...state, palettes: state.palettes.filter((n2) => !gone.includes(n2)) };
10774
11843
  sync(home, catalog, next);
10775
11844
  forget(home, catalog, state.terminals, gone);
10776
11845
  writeInstalled(home, next);
@@ -10813,17 +11882,15 @@ async function runUpdate() {
10813
11882
  const added = catalog.palettes.length - before;
10814
11883
  console.log(`catalog ${catalog.version} — ${catalog.palettes.length} palettes${added > 0 ? ` (+${added})` : ""}`);
10815
11884
  }
10816
- async function runBrowse(scope = "palette") {
10817
- const home = configHome();
10818
- const catalog = readCatalog(home);
10819
- const state = readInstalled(home);
11885
+ async function pickPalettes(catalog, installed, scope, required = false) {
10820
11886
  const entries = process.env.TTHEME_SORT === "series" ? catalog.palettes : alphabetical(catalog.palettes);
10821
11887
  const live = process.stdout.isTTY === true && !process.env.NO_COLOR;
10822
11888
  const saved = live ? await queryTerminalColors() : new Map;
10823
11889
  const prompt = new PalettePrompt({
10824
11890
  entries,
10825
11891
  scope,
10826
- installed: state.palettes,
11892
+ installed,
11893
+ required,
10827
11894
  color: !process.env.NO_COLOR,
10828
11895
  fx: promptFx(process.env.TTHEME_FX),
10829
11896
  onFocus: live ? (entry) => process.stdout.write(paletteOsc(entry)) : undefined
@@ -10831,12 +11898,21 @@ async function runBrowse(scope = "palette") {
10831
11898
  const done = await prompt.prompt();
10832
11899
  process.stdout.write(restoreOsc(saved));
10833
11900
  if (isCancel(done)) {
11901
+ return;
11902
+ }
11903
+ return catalog.palettes.filter((e) => prompt.picked.has(e.name)).map((e) => e.name);
11904
+ }
11905
+ async function runBrowse() {
11906
+ const home = configHome();
11907
+ const catalog = readCatalog(home);
11908
+ const state = readInstalled(home);
11909
+ const wanted = await pickPalettes(catalog, state.palettes, "palette");
11910
+ if (!wanted) {
10834
11911
  console.log("nothing changed");
10835
11912
  return;
10836
11913
  }
10837
- const wanted = catalog.palettes.filter((e) => prompt.picked.has(e.name)).map((e) => e.name);
10838
- const dropped = state.palettes.filter((n3) => !prompt.picked.has(n3));
10839
- const added = wanted.filter((n3) => !state.palettes.includes(n3));
11914
+ const dropped = state.palettes.filter((n2) => !wanted.includes(n2));
11915
+ const added = wanted.filter((n2) => !state.palettes.includes(n2));
10840
11916
  if (added.length === 0 && dropped.length === 0) {
10841
11917
  console.log("nothing changed");
10842
11918
  return;
@@ -10860,6 +11936,9 @@ function copyDir(copies, from, to) {
10860
11936
  copies.push({ from: join8(from, f2), to: join8(to, f2) });
10861
11937
  }
10862
11938
  }
11939
+ function loadManifest(root2) {
11940
+ return JSON.parse(readFileSync6(join8(root2, "dist", "manifest.json"), "utf8"));
11941
+ }
10863
11942
  function planInit(opts, paths) {
10864
11943
  const dist = join8(paths.root, "dist");
10865
11944
  const home = join8(paths.configHome, "ttheme");
@@ -10874,7 +11953,7 @@ function planInit(opts, paths) {
10874
11953
  const configPath = join8(home, "config.zsh");
10875
11954
  const settings2 = {
10876
11955
  file: configPath,
10877
- content: configFile(existsSync5(configPath) ? readFileSync5(configPath, "utf8") : "", opts)
11956
+ content: configFile(existsSync5(configPath) ? readFileSync6(configPath, "utf8") : "")
10878
11957
  };
10879
11958
  const notes = [];
10880
11959
  if (opts.terminals.includes("ghostty")) {
@@ -10882,22 +11961,21 @@ function planInit(opts, paths) {
10882
11961
  }
10883
11962
  if (opts.terminals.includes("alacritty")) {
10884
11963
  const config = join8(paths.configHome, "alacritty", "alacritty.toml");
10885
- if (existsSync5(config) && !readFileSync5(config, "utf8").includes("# ttheme begin")) {
11964
+ if (existsSync5(config) && !readFileSync6(config, "utf8").includes("# ttheme begin")) {
10886
11965
  notes.push("alacritty.toml already exists — ttheme left it alone; add its themes/ import yourself");
10887
11966
  }
10888
11967
  }
10889
11968
  notes.push("wezterm and iterm2: import the palettes you install from the release archive");
10890
- const catalog = JSON.parse(readFileSync5(join8(dist, "manifest.json"), "utf8"));
10891
- const installed = { terminals: opts.terminals, palettes: [] };
10892
- return { copies, edits, settings: settings2, catalog, installed, notes };
11969
+ const installed = { terminals: opts.terminals, palettes: opts.palettes };
11970
+ return { copies, edits, settings: settings2, catalog: loadManifest(paths.root), installed, notes };
10893
11971
  }
10894
11972
  function applyInit(plan) {
10895
- for (const c3 of plan.copies) {
10896
- mkdirSync5(dirname4(c3.to), { recursive: true });
10897
- rmSync5(c3.to, { force: true });
10898
- copyFileSync(c3.from, c3.to);
10899
- if (c3.executable) {
10900
- chmodSync(c3.to, 493);
11973
+ for (const c2 of plan.copies) {
11974
+ mkdirSync5(dirname4(c2.to), { recursive: true });
11975
+ rmSync5(c2.to, { force: true });
11976
+ copyFileSync(c2.from, c2.to);
11977
+ if (c2.executable) {
11978
+ chmodSync(c2.to, 493);
10901
11979
  }
10902
11980
  }
10903
11981
  mkdirSync5(dirname4(plan.settings.file), { recursive: true });
@@ -10908,7 +11986,7 @@ function applyInit(plan) {
10908
11986
  sync(configHome2, plan.catalog, plan.installed);
10909
11987
  for (const e of plan.edits) {
10910
11988
  mkdirSync5(dirname4(e.file), { recursive: true });
10911
- const current = existsSync5(e.file) ? readFileSync5(e.file, "utf8") : "";
11989
+ const current = existsSync5(e.file) ? readFileSync6(e.file, "utf8") : "";
10912
11990
  writeFileSync5(e.file, upsertBlock(current, e.block));
10913
11991
  }
10914
11992
  }
@@ -10919,36 +11997,59 @@ function accepted(value) {
10919
11997
  }
10920
11998
  return value;
10921
11999
  }
10922
- async function ask(detected, preselected) {
10923
- intro("ttheme init");
10924
- const terminals = accepted(await multiselect({
12000
+ async function askTerminals(detected, preselected) {
12001
+ return accepted(await multiselect({
10925
12002
  message: "wire which terminals?",
10926
12003
  options: INIT_TERMINALS.map((t2) => ({ value: t2, hint: t2 === detected ? "detected" : undefined })),
10927
12004
  initialValues: preselected,
10928
12005
  required: true
10929
12006
  }));
10930
- const tabPalette = accepted(await select({
10931
- message: "new tabs",
10932
- options: [
10933
- { value: "seq", label: "rotate through the palettes", hint: "default" },
10934
- { value: "off", label: "inherit the window colors" }
10935
- ],
10936
- initialValue: "seq"
10937
- }));
10938
- const announce = accepted(await confirm({ message: 'show the palette name under "Last login:"?', initialValue: true }));
10939
- return { terminals, tabPalette, announce };
10940
12007
  }
10941
- function report(plan, opts, interactive) {
12008
+ function verify(plan) {
10942
12009
  const missing = [
10943
- ...plan.copies.filter((c3) => !existsSync5(c3.to)).map((c3) => c3.to),
12010
+ ...plan.copies.filter((c2) => !existsSync5(c2.to)).map((c2) => c2.to),
10944
12011
  ...existsSync5(plan.settings.file) ? [] : [plan.settings.file],
10945
- ...plan.edits.filter((e) => !readFileSync5(e.file, "utf8").includes("# ttheme begin")).map((e) => e.file)
12012
+ ...plan.edits.filter((e) => !readFileSync6(e.file, "utf8").includes("# ttheme begin")).map((e) => e.file)
10946
12013
  ];
10947
12014
  if (missing.length > 0) {
10948
12015
  throw new Error(`init left gaps:
10949
12016
  ${missing.join(`
10950
12017
  `)}`);
10951
12018
  }
12019
+ }
12020
+ function seriesOf(catalog, names) {
12021
+ return [...new Set(catalog.palettes.filter((e) => names.includes(e.name)).map((e) => e.group))];
12022
+ }
12023
+ function paintStartup(catalog, installed) {
12024
+ const startup = catalog.palettes.find((e) => e.name === startupPalette(installed));
12025
+ const live = process.stdout.isTTY === true && !process.env.NO_COLOR && !process.env.TMUX;
12026
+ if (!startup || !live) {
12027
+ return false;
12028
+ }
12029
+ process.stdout.write(paletteOsc(startup));
12030
+ return true;
12031
+ }
12032
+ function receipt(plan, opts, painted) {
12033
+ const series = seriesOf(plan.catalog, opts.palettes);
12034
+ const startup = startupPalette(plan.installed);
12035
+ note([
12036
+ `${series.join(", ")} (${opts.palettes.length})`,
12037
+ `startup ${startup}${painted ? " — this tab wears it already" : ""}`,
12038
+ "new tabs rotate through the palettes — change with `ttheme config`"
12039
+ ].join(`
12040
+ `), `installed ${opts.palettes.length} palettes`);
12041
+ const next = ["exec zsh the ttheme command in this tab"];
12042
+ if (opts.terminals.includes("ghostty")) {
12043
+ next.push("restart ghostty new tabs pick up its config");
12044
+ }
12045
+ if (opts.terminals.includes("kitty")) {
12046
+ next.push("new kitty window picks up its config");
12047
+ }
12048
+ note([...next, ...plan.notes].join(`
12049
+ `), "next");
12050
+ outro("done");
12051
+ }
12052
+ function report(plan, opts) {
10952
12053
  const lines = [
10953
12054
  `placed ${plan.copies.length} files`,
10954
12055
  `settings in ${plan.settings.file} — edit later with \`ttheme config\``,
@@ -10961,15 +12062,9 @@ ${missing.join(`
10961
12062
  lines.push("open a new kitty window to pick up its config");
10962
12063
  }
10963
12064
  lines.push(...plan.notes);
10964
- lines.push("no palettes yet — `ttheme browse` picks them from the catalog");
10965
- if (interactive) {
10966
- note(lines.join(`
10967
- `), "done");
10968
- } else {
10969
- console.log(lines.join(`
12065
+ lines.push("no palettes yet — open a new shell (`exec zsh`), then `ttheme browse` picks them from the catalog");
12066
+ console.log(lines.join(`
10970
12067
  `));
10971
- console.log("run `ttheme browse` to pick your palettes");
10972
- }
10973
12068
  }
10974
12069
  async function runInit(flags = {}) {
10975
12070
  const root2 = join8(import.meta.dirname, "..");
@@ -10989,40 +12084,40 @@ async function runInit(flags = {}) {
10989
12084
  const detected = detectTerminal(process.env);
10990
12085
  const preselected = INIT_TERMINALS.filter((t2) => t2 === detected || existsSync5(join8(configHome2, t2)));
10991
12086
  const interactive = !flags.yes && process.stdin.isTTY === true && process.stdout.isTTY === true;
10992
- let opts;
10993
- if (interactive) {
10994
- opts = await ask(detected, preselected);
10995
- } else {
12087
+ if (!interactive) {
10996
12088
  if (preselected.length === 0) {
10997
12089
  throw new Error("no supported terminal detected — run this inside ghostty, kitty or alacritty");
10998
12090
  }
10999
- opts = { terminals: preselected, tabPalette: "seq", announce: true };
12091
+ const opts2 = { terminals: preselected, palettes: [] };
12092
+ const plan2 = planInit(opts2, paths);
12093
+ applyInit(plan2);
12094
+ verify(plan2);
12095
+ report(plan2, opts2);
12096
+ return;
12097
+ }
12098
+ intro("ttheme init");
12099
+ const terminals = await askTerminals(detected, preselected);
12100
+ const palettes = await pickPalettes(loadManifest(root2), [], "series", true);
12101
+ if (!palettes) {
12102
+ cancel("nothing changed");
12103
+ process.exit(1);
11000
12104
  }
12105
+ const opts = { terminals, palettes };
11001
12106
  const plan = planInit(opts, paths);
11002
- if (interactive) {
11003
- note([
11004
- `copy ${plan.copies.length} files under ${configHome2}`,
11005
- `write ${plan.settings.file}`,
11006
- ...plan.edits.map((e) => `edit ${e.file}`)
11007
- ].join(`
11008
- `), `wiring ${opts.terminals.join(", ")}`);
11009
- const go = accepted(await confirm({ message: "apply these changes?" }));
11010
- if (!go) {
11011
- cancel("nothing changed");
11012
- process.exit(1);
11013
- }
12107
+ note([
12108
+ `install ${palettes.length} palettes — ${seriesOf(plan.catalog, palettes).join(", ")}`,
12109
+ `copy ${plan.copies.length} files under ${configHome2}`,
12110
+ `write ${plan.settings.file}`,
12111
+ ...plan.edits.map((e) => `edit ${e.file}`)
12112
+ ].join(`
12113
+ `), `wiring ${terminals.join(", ")}`);
12114
+ if (!accepted(await confirm({ message: "apply these changes?" }))) {
12115
+ cancel("nothing changed");
12116
+ process.exit(1);
11014
12117
  }
11015
12118
  applyInit(plan);
11016
- report(plan, opts, interactive);
11017
- if (!interactive) {
11018
- return;
11019
- }
11020
- const browse = accepted(await confirm({ message: "pick the series to install now?", initialValue: true }));
11021
- if (browse) {
11022
- await runBrowse("series");
11023
- } else {
11024
- outro("run `ttheme browse` when you are ready");
11025
- }
12119
+ verify(plan);
12120
+ receipt(plan, opts, paintStartup(plan.catalog, plan.installed));
11026
12121
  }
11027
12122
 
11028
12123
  // src/cli.ts