honeytree 1.1.5 → 1.2.0

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.
@@ -0,0 +1,151 @@
1
+ import { ANIMATION_KEYFRAMES } from "./animation-keyframes.js";
2
+ import { getSprite } from "./sprites.js";
3
+
4
+ const CHAR_ORDER = [" ", "░", "▒", "█"];
5
+
6
+ function charIndex(ch) {
7
+ const idx = CHAR_ORDER.indexOf(ch);
8
+ return idx >= 0 ? idx : 0;
9
+ }
10
+
11
+ function interpolateChar(ch1, ch2, t) {
12
+ const i1 = charIndex(ch1);
13
+ const i2 = charIndex(ch2);
14
+ const idx = Math.round(i1 + (i2 - i1) * t);
15
+ return CHAR_ORDER[Math.max(0, Math.min(CHAR_ORDER.length - 1, idx))];
16
+ }
17
+
18
+ function parseHex(hex) {
19
+ const h = hex.startsWith("#") ? hex.slice(1) : hex;
20
+ return {
21
+ r: parseInt(h.slice(0, 2), 16),
22
+ g: parseInt(h.slice(2, 4), 16),
23
+ b: parseInt(h.slice(4, 6), 16),
24
+ };
25
+ }
26
+
27
+ function toHex({ r, g, b }) {
28
+ const c = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0");
29
+ return `#${c(r)}${c(g)}${c(b)}`;
30
+ }
31
+
32
+ function lerpColor(hex1, hex2, t) {
33
+ const c1 = parseHex(hex1);
34
+ const c2 = parseHex(hex2);
35
+ return toHex({
36
+ r: c1.r + (c2.r - c1.r) * t,
37
+ g: c1.g + (c2.g - c1.g) * t,
38
+ b: c1.b + (c2.b - c1.b) * t,
39
+ });
40
+ }
41
+
42
+ function interpolateSprite(sprite1, sprite2, t) {
43
+ const rows = sprite1.rows.map((row, r) =>
44
+ row.map(([ch1, col1], c) => {
45
+ const [ch2, col2] = sprite2.rows[r][c];
46
+ if (!col1 && !col2) return [" ", null];
47
+ if (!col1 && col2) {
48
+ const ch = interpolateChar(" ", ch2, t);
49
+ if (ch === " ") return [" ", null];
50
+ return [ch, col2];
51
+ }
52
+ if (col1 && !col2) {
53
+ const ch = interpolateChar(ch1, " ", t);
54
+ if (ch === " ") return [" ", null];
55
+ return [ch, col1];
56
+ }
57
+ const ch = interpolateChar(ch1, ch2, t);
58
+ const col = lerpColor(col1, col2, t);
59
+ return [ch, col];
60
+ }),
61
+ );
62
+ return { rows, width: sprite1.width };
63
+ }
64
+
65
+ function brightenSprite(sprite, amount) {
66
+ const rows = sprite.rows.map((row) =>
67
+ row.map(([ch, col]) => {
68
+ if (!col) return [ch, col];
69
+ const c = parseHex(col);
70
+ return [ch, toHex({
71
+ r: Math.min(255, c.r + (255 - c.r) * amount),
72
+ g: Math.min(255, c.g + (255 - c.g) * amount),
73
+ b: Math.min(255, c.b + (255 - c.b) * amount),
74
+ })];
75
+ }),
76
+ );
77
+ return { rows, width: sprite.width };
78
+ }
79
+
80
+ export function getAnimationFrames(type, growth, frameCount = 40) {
81
+ const allKeyframes = ANIMATION_KEYFRAMES[type];
82
+ if (!allKeyframes) throw new Error(`No animation keyframes for type: ${type}`);
83
+
84
+ const finalSprite = getSprite(type, growth);
85
+
86
+ let keyframes;
87
+ if (growth >= 0.8) {
88
+ keyframes = allKeyframes;
89
+ } else {
90
+ let maxKF;
91
+ if (growth < 0.2) maxKF = 3;
92
+ else if (growth < 0.5) maxKF = 6;
93
+ else maxKF = 8;
94
+
95
+ keyframes = allKeyframes.slice(0, maxKF);
96
+ const lastTime = keyframes[keyframes.length - 1].time;
97
+ const timeScale = 4.0 / Math.max(lastTime, 0.1);
98
+
99
+ keyframes = keyframes.map((kf) => ({ ...kf, time: kf.time * timeScale }));
100
+ keyframes.push({ time: 4.0, sprite: finalSprite });
101
+ keyframes.push({ time: 4.5, sprite: brightenSprite(finalSprite, 0.2) });
102
+ }
103
+
104
+ const totalDuration = 5.0;
105
+ const frames = [];
106
+
107
+ for (let i = 0; i < frameCount; i++) {
108
+ const t = (i / (frameCount - 1)) * totalDuration;
109
+
110
+ let kfBefore = keyframes[0];
111
+ let kfAfter = keyframes[0];
112
+
113
+ for (let k = 0; k < keyframes.length - 1; k++) {
114
+ if (t >= keyframes[k].time && t <= keyframes[k + 1].time) {
115
+ kfBefore = keyframes[k];
116
+ kfAfter = keyframes[k + 1];
117
+ break;
118
+ }
119
+ if (k === keyframes.length - 2) {
120
+ kfBefore = keyframes[keyframes.length - 1];
121
+ kfAfter = keyframes[keyframes.length - 1];
122
+ }
123
+ }
124
+
125
+ const segmentDuration = kfAfter.time - kfBefore.time;
126
+ const segmentT = segmentDuration > 0 ? (t - kfBefore.time) / segmentDuration : 1;
127
+ const clampedT = Math.max(0, Math.min(1, segmentT));
128
+
129
+ const sprite = interpolateSprite(kfBefore.sprite, kfAfter.sprite, clampedT);
130
+
131
+ let groundOverlay = null;
132
+ if (t < 0.5 && kfBefore.groundEffect) {
133
+ const ge = kfBefore.groundEffect;
134
+ groundOverlay = [];
135
+ for (let dx = -ge.radius; dx <= ge.radius; dx++) {
136
+ const intensity = 1 - Math.abs(dx) / ge.radius;
137
+ if (intensity > 0.3) {
138
+ groundOverlay.push({ dx, char: "░", color: ge.color });
139
+ }
140
+ }
141
+ }
142
+
143
+ if (i === frameCount - 1) {
144
+ frames.push({ sprite: finalSprite, groundOverlay: null, groundPulse: false });
145
+ } else {
146
+ frames.push({ sprite, groundOverlay, groundPulse: i < 3 });
147
+ }
148
+ }
149
+
150
+ return frames;
151
+ }
package/src/camera.js ADDED
@@ -0,0 +1,54 @@
1
+ // src/camera.js
2
+
3
+ const DEG_TO_RAD = Math.PI / 180;
4
+
5
+ export function createCamera() {
6
+ return {
7
+ azimuth: 45,
8
+ elevation: 30,
9
+ distance: 60,
10
+ };
11
+ }
12
+
13
+ export function rotatePoint(x, y, z, azimuthDeg, elevationDeg) {
14
+ const az = azimuthDeg * DEG_TO_RAD;
15
+ const el = elevationDeg * DEG_TO_RAD;
16
+
17
+ const cosAz = Math.cos(az);
18
+ const sinAz = Math.sin(az);
19
+ const x1 = x * cosAz + z * sinAz;
20
+ const z1 = -x * sinAz + z * cosAz;
21
+
22
+ const cosEl = Math.cos(el);
23
+ const sinEl = Math.sin(el);
24
+ const y1 = y * cosEl - z1 * sinEl;
25
+ const z2 = y * sinEl + z1 * cosEl;
26
+
27
+ return [x1, y1, z2];
28
+ }
29
+
30
+ export function projectPoint(x, y, z, screenWidth, screenHeight, distance = 25) {
31
+ const fov = 60;
32
+ const fovRad = fov * DEG_TO_RAD;
33
+ const focalLength = screenHeight / (2 * Math.tan(fovRad / 2));
34
+
35
+ const zView = z - distance;
36
+
37
+ if (zView >= -1) {
38
+ return { screenX: -1, screenY: -1, depth: Infinity, visible: false };
39
+ }
40
+
41
+ const scale = focalLength / -zView;
42
+ const screenX = Math.round(x * scale * 2 + screenWidth / 2);
43
+ const screenY = Math.round(-y * scale + screenHeight / 2);
44
+
45
+ return { screenX, screenY, depth: -zView, visible: true };
46
+ }
47
+
48
+ export function clampElevation(elevation) {
49
+ return Math.max(10, Math.min(80, elevation));
50
+ }
51
+
52
+ export function clampAzimuth(azimuth) {
53
+ return ((azimuth % 360) + 360) % 360;
54
+ }
@@ -0,0 +1,32 @@
1
+ import { execSync } from "node:child_process";
2
+ import { hunkToPatch } from "./diffparser.js";
3
+
4
+ export function stageHunk(rootDir, filePath, hunk) {
5
+ try {
6
+ const patch = hunkToPatch(filePath, hunk);
7
+ execSync("git apply --cached --unidiff-zero -", {
8
+ cwd: rootDir,
9
+ input: patch,
10
+ timeout: 5000,
11
+ stdio: ["pipe", "pipe", "pipe"],
12
+ });
13
+ return { ok: true };
14
+ } catch (err) {
15
+ return { ok: false, error: err.message };
16
+ }
17
+ }
18
+
19
+ export function revertHunk(rootDir, filePath, hunk) {
20
+ try {
21
+ const patch = hunkToPatch(filePath, hunk);
22
+ execSync("git apply --reverse --unidiff-zero -", {
23
+ cwd: rootDir,
24
+ input: patch,
25
+ timeout: 5000,
26
+ stdio: ["pipe", "pipe", "pipe"],
27
+ });
28
+ return { ok: true };
29
+ } catch (err) {
30
+ return { ok: false, error: err.message };
31
+ }
32
+ }
@@ -0,0 +1,83 @@
1
+ // src/diffpanel.js
2
+ import chalk from "chalk";
3
+
4
+ chalk.level = 3;
5
+
6
+ export function createDiffPanel(filePath, hunks) {
7
+ return {
8
+ filePath,
9
+ hunks,
10
+ currentHunk: 0,
11
+ hunkStatus: hunks.map(() => "pending"),
12
+ scrollOffset: 0,
13
+ };
14
+ }
15
+
16
+ export function renderDiffPanel(panel, width, height) {
17
+ const lines = [];
18
+ const totalAdded = panel.hunks.reduce((s, h) => s + h.added, 0);
19
+ const totalRemoved = panel.hunks.reduce((s, h) => s + h.removed, 0);
20
+
21
+ // Header
22
+ const header = ` ${panel.filePath} +${totalAdded} -${totalRemoved} ${panel.hunks.length} hunks`;
23
+ lines.push(chalk.hex("#f5a50b").bold(header.padEnd(width).slice(0, width)));
24
+
25
+ // Separator
26
+ lines.push(chalk.hex("#555555")("─".repeat(width)));
27
+
28
+ // Help line
29
+ const help = " j/k: navigate a: accept r: reject A/R: all Esc: close";
30
+ lines.push(chalk.hex("#888888")(help.padEnd(width).slice(0, width)));
31
+
32
+ lines.push(chalk.hex("#555555")("─".repeat(width)));
33
+
34
+ // Render hunks
35
+ const bodyHeight = height - lines.length;
36
+ const bodyLines = [];
37
+
38
+ for (let hi = 0; hi < panel.hunks.length; hi++) {
39
+ const hunk = panel.hunks[hi];
40
+ const isCurrent = hi === panel.currentHunk;
41
+ const status = panel.hunkStatus[hi];
42
+
43
+ // Hunk header with status
44
+ let statusIcon = "[ ]";
45
+ if (status === "accepted") statusIcon = chalk.green("[✓]");
46
+ else if (status === "rejected") statusIcon = chalk.red("[✗]");
47
+
48
+ const hunkHeader = isCurrent
49
+ ? chalk.hex("#ffcc44").bold(`▸ Hunk ${hi + 1}/${panel.hunks.length} ${statusIcon} ${hunk.header}`)
50
+ : chalk.hex("#888888")(` Hunk ${hi + 1}/${panel.hunks.length} ${statusIcon} ${hunk.header}`);
51
+
52
+ bodyLines.push(hunkHeader.padEnd(width).slice(0, width));
53
+
54
+ // Hunk lines
55
+ for (const line of hunk.lines) {
56
+ let rendered;
57
+ const text = line.text.padEnd(width).slice(0, width);
58
+ if (line.type === "added") {
59
+ rendered = chalk.green(text);
60
+ } else if (line.type === "removed") {
61
+ rendered = chalk.red(text);
62
+ } else {
63
+ rendered = isCurrent ? chalk.hex("#cccccc")(text) : chalk.hex("#666666")(text);
64
+ }
65
+ bodyLines.push(rendered);
66
+ }
67
+
68
+ bodyLines.push(""); // blank line between hunks
69
+ }
70
+
71
+ // Apply scroll offset and fill to height
72
+ const scrolled = bodyLines.slice(panel.scrollOffset, panel.scrollOffset + bodyHeight);
73
+ for (const line of scrolled) {
74
+ lines.push(line);
75
+ }
76
+
77
+ // Pad remaining height
78
+ while (lines.length < height) {
79
+ lines.push(" ".repeat(width));
80
+ }
81
+
82
+ return lines.slice(0, height);
83
+ }
@@ -0,0 +1,44 @@
1
+ export function parseDiff(diffText) {
2
+ if (!diffText || !diffText.trim()) return [];
3
+
4
+ const lines = diffText.split("\n");
5
+ const hunks = [];
6
+ let currentHunk = null;
7
+
8
+ for (const line of lines) {
9
+ if (line.startsWith("@@")) {
10
+ if (currentHunk) {
11
+ currentHunk.added = currentHunk.lines.filter(l => l.type === "added").length;
12
+ currentHunk.removed = currentHunk.lines.filter(l => l.type === "removed").length;
13
+ hunks.push(currentHunk);
14
+ }
15
+ currentHunk = { header: line, lines: [], added: 0, removed: 0 };
16
+ } else if (currentHunk) {
17
+ if (line.startsWith("+")) {
18
+ currentHunk.lines.push({ type: "added", text: line });
19
+ } else if (line.startsWith("-")) {
20
+ currentHunk.lines.push({ type: "removed", text: line });
21
+ } else if (line.startsWith(" ") || line === "") {
22
+ currentHunk.lines.push({ type: "context", text: line });
23
+ }
24
+ }
25
+ }
26
+
27
+ if (currentHunk) {
28
+ currentHunk.added = currentHunk.lines.filter(l => l.type === "added").length;
29
+ currentHunk.removed = currentHunk.lines.filter(l => l.type === "removed").length;
30
+ hunks.push(currentHunk);
31
+ }
32
+
33
+ return hunks;
34
+ }
35
+
36
+ export function hunkToPatch(filePath, hunk) {
37
+ const lines = [
38
+ `--- a/${filePath}`,
39
+ `+++ b/${filePath}`,
40
+ hunk.header,
41
+ ...hunk.lines.map(l => l.text),
42
+ ];
43
+ return lines.join("\n") + "\n";
44
+ }
@@ -0,0 +1,52 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ function execAsync(cmd, args, opts) {
6
+ return new Promise((resolve) => {
7
+ execFile(cmd, args, { timeout: 5000, encoding: "utf-8", ...opts }, (err, stdout) => {
8
+ resolve(err ? "" : stdout);
9
+ });
10
+ });
11
+ }
12
+
13
+ export async function getChangedFiles(rootDir) {
14
+ const changed = new Set();
15
+
16
+ const [tracked, untracked] = await Promise.all([
17
+ execAsync("git", ["diff", "--name-only"], { cwd: rootDir }),
18
+ execAsync("git", ["ls-files", "--others", "--exclude-standard"], { cwd: rootDir }),
19
+ ]);
20
+
21
+ for (const output of [tracked, untracked]) {
22
+ for (const line of output.split("\n")) {
23
+ const trimmed = line.trim();
24
+ if (trimmed) changed.add(trimmed);
25
+ }
26
+ }
27
+
28
+ return changed;
29
+ }
30
+
31
+ export function getFileDiff(rootDir, filePath) {
32
+ return execAsync("git", ["diff", "-U3", "--", filePath], { cwd: rootDir });
33
+ }
34
+
35
+ export function watchForChanges(rootDir, callback) {
36
+ let debounceTimer = null;
37
+ const debounced = () => {
38
+ if (debounceTimer) clearTimeout(debounceTimer);
39
+ debounceTimer = setTimeout(callback, 300);
40
+ };
41
+
42
+ try {
43
+ const watcher = fs.watch(rootDir, { recursive: true }, (event, filename) => {
44
+ if (!filename) return;
45
+ if (filename.includes("node_modules") || filename.includes(".git")) return;
46
+ debounced();
47
+ });
48
+ return watcher;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
package/src/migrate.js ADDED
@@ -0,0 +1,47 @@
1
+ import { getVirtualWidth } from "./plant.js";
2
+
3
+ function hash(seed) {
4
+ let value = seed >>> 0;
5
+ value = Math.imul((value >>> 16) ^ value, 0x45d9f3b) >>> 0;
6
+ value = Math.imul((value >>> 16) ^ value, 0x45d9f3b) >>> 0;
7
+ return ((value >>> 16) ^ value) >>> 0;
8
+ }
9
+
10
+ export function migrateLayout(forest, termWidth) {
11
+ if (forest.layoutVersion >= 2) return forest;
12
+
13
+ const trees = forest.trees;
14
+ if (trees.length === 0) {
15
+ forest.layoutVersion = 2;
16
+ return forest;
17
+ }
18
+
19
+ const virtualWidth = getVirtualWidth(trees.length, termWidth);
20
+ const margin = 6;
21
+ const usable = virtualWidth - margin * 2;
22
+
23
+ // Sort by current x to preserve left-to-right order
24
+ const sorted = [...trees].sort((a, b) => a.x - b.x);
25
+
26
+ // Spread evenly across usable width with deterministic jitter
27
+ const gap = trees.length === 1 ? 0 : usable / (trees.length - 1);
28
+
29
+ for (let i = 0; i < sorted.length; i++) {
30
+ const baseX = trees.length === 1
31
+ ? Math.round(virtualWidth / 2)
32
+ : Math.round(margin + i * gap);
33
+ // Deterministic jitter: +/-2 based on tree id
34
+ const jitter = (hash(sorted[i].id * 7 + 31) % 5) - 2;
35
+ sorted[i].x = Math.max(margin, Math.min(virtualWidth - margin, baseX + jitter));
36
+ }
37
+
38
+ // Ensure order is preserved after jitter — nudge if needed
39
+ for (let i = 1; i < sorted.length; i++) {
40
+ if (sorted[i].x <= sorted[i - 1].x) {
41
+ sorted[i].x = sorted[i - 1].x + 1;
42
+ }
43
+ }
44
+
45
+ forest.layoutVersion = 2;
46
+ return forest;
47
+ }
package/src/plant.js CHANGED
@@ -1,14 +1,22 @@
1
1
  import { getSprite, TREE_TYPES } from "./sprites.js";
2
2
  import { createEmptyForest, readForest, writeForest } from "./state.js";
3
3
  import { findBadgeFile, writeBadgeSVG } from "./badge.js";
4
+ import { migrateLayout } from "./migrate.js";
4
5
 
5
- const MIN_GAP = 4;
6
+ const MIN_GAP = 6;
6
7
  const DEFAULT_WIDTH = 80;
8
+ const TREE_SPACING = 6;
9
+
10
+ export function getVirtualWidth(treeCount, termWidth) {
11
+ return Math.max(termWidth, treeCount * TREE_SPACING);
12
+ }
7
13
 
8
14
  function getPlantWidth(forest) {
9
- // Use the width saved by the viewer, fall back to default
10
- if (forest.viewerWidth && forest.viewerWidth > 40) return forest.viewerWidth;
11
- return DEFAULT_WIDTH;
15
+ const termWidth = forest.viewerWidth && forest.viewerWidth > 40
16
+ ? forest.viewerWidth
17
+ : DEFAULT_WIDTH;
18
+ const treeCount = forest.trees.length + 1;
19
+ return getVirtualWidth(treeCount, termWidth);
12
20
  }
13
21
 
14
22
  function randomItem(items) {
@@ -64,6 +72,14 @@ export async function plant() {
64
72
  const forest = readForest() ?? createEmptyForest();
65
73
  const width = getPlantWidth(forest);
66
74
 
75
+ // Migrate old layouts to use virtual width
76
+ if (!forest.layoutVersion || forest.layoutVersion < 2) {
77
+ const termWidth = forest.viewerWidth && forest.viewerWidth > 40
78
+ ? forest.viewerWidth
79
+ : DEFAULT_WIDTH;
80
+ migrateLayout(forest, termWidth);
81
+ }
82
+
67
83
  // Update streak
68
84
  const today = new Date().toISOString().slice(0, 10);
69
85
  if (forest.lastActiveDate) {