@mingxy/opencode-mascot 0.2.2 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mingxy/opencode-mascot",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "OpenCode TUI mascot plugin framework - customizable ASCII mascots for your terminal",
5
5
  "author": "mingxy",
6
6
  "license": "MIT",
@@ -5,6 +5,7 @@ import type { JSX } from "@opentui/solid";
5
5
  import type { MascotPack } from "../core/types";
6
6
  import { createAnimatedRenderer } from "../core/ascii-renderer";
7
7
  import { onCelebrate } from "../core/celebration-bus";
8
+ import { useDraggableMascot } from "./use-draggable-mascot";
8
9
 
9
10
  interface HomeMascotProps {
10
11
  mascots: Record<string, MascotPack>;
@@ -20,26 +21,29 @@ export function HomeMascot(props: HomeMascotProps): JSX.Element {
20
21
  const initialName = names[Math.floor(Math.random() * names.length)];
21
22
 
22
23
  const [currentName, setCurrentName] = createSignal(initialName);
23
- const [posX, setPosX] = createSignal(0);
24
- const [posY, setPosY] = createSignal(0);
25
- let dragStartX = 0;
26
- let dragStartY = 0;
27
- let dragAnchorX = 0;
28
- let dragAnchorY = 0;
29
- let lastClickTime = 0;
30
- let isDragging = false;
31
24
 
32
25
  const renderers: Record<string, ReturnType<typeof createAnimatedRenderer>> = {};
33
26
  for (const [name, pack] of Object.entries(props.mascots)) {
34
27
  renderers[name] = createAnimatedRenderer(pack);
35
28
  }
36
29
 
37
- const switchTo = (name: string) => {
38
- if (props.mascots[name] && name !== currentName()) {
39
- setCurrentName(name);
40
- }
30
+ const switchToNext = () => {
31
+ const cur = currentName();
32
+ const idx = names.indexOf(cur);
33
+ setCurrentName(names[(idx + 1) % names.length]);
41
34
  };
42
35
 
36
+ const { posX, posY, mouseProps } = useDraggableMascot({
37
+ initialX: 0,
38
+ initialY: 0,
39
+ mascotWidth: 10,
40
+ mascotHeight: 5,
41
+ onSwitch: switchToNext,
42
+ clearSelection: () => props.api.renderer.clearSelection(),
43
+ setDragging: (v) => renderers[currentName()].setDragging(v),
44
+ enableEdge: false,
45
+ });
46
+
43
47
  onCelebrate((newVersion) => {
44
48
  renderers[currentName()].celebrateUpdate(newVersion);
45
49
  });
@@ -51,51 +55,7 @@ export function HomeMascot(props: HomeMascotProps): JSX.Element {
51
55
  alignItems="center"
52
56
  zIndex={100}
53
57
  flexDirection="column"
54
- onMouseDown={(e: any) => {
55
- const now = Date.now();
56
- if (now - lastClickTime < 300) {
57
- const cur = currentName();
58
- const idx = names.indexOf(cur);
59
- const next = names[(idx + 1) % names.length];
60
- switchTo(next);
61
- lastClickTime = 0;
62
- return;
63
- }
64
- lastClickTime = now;
65
-
66
- if (e.modifiers?.alt) {
67
- dragStartX = e.x;
68
- dragStartY = e.y;
69
- dragAnchorX = posX();
70
- dragAnchorY = posY();
71
- isDragging = true;
72
- renderers[currentName()].setDragging(true);
73
- e.preventDefault();
74
- e.stopPropagation();
75
- props.api.renderer.clearSelection();
76
- }
77
- }}
78
- onMouseDrag={(e: any) => {
79
- if (e.modifiers?.alt && isDragging) {
80
- setPosX(dragAnchorX + (e.x - dragStartX));
81
- setPosY(dragAnchorY + (e.y - dragStartY));
82
- e.preventDefault();
83
- e.stopPropagation();
84
- props.api.renderer.clearSelection();
85
- }
86
- }}
87
- onMouseUp={() => {
88
- if (isDragging) {
89
- isDragging = false;
90
- renderers[currentName()].setDragging(false);
91
- }
92
- }}
93
- onMouseDragEnd={() => {
94
- if (isDragging) {
95
- isDragging = false;
96
- renderers[currentName()].setDragging(false);
97
- }
98
- }}
58
+ {...mouseProps}
99
59
  >
100
60
  {renderers[currentName()]?.element() ?? null}
101
61
  </box>
@@ -5,6 +5,7 @@ import type { JSX } from "@opentui/solid";
5
5
  import type { MascotPack, MascotState, SwitchConfig } from "../core/types";
6
6
  import { createAnimatedRenderer } from "../core/ascii-renderer";
7
7
  import { onCelebrate } from "../core/celebration-bus";
8
+ import { useDraggableMascot } from "./use-draggable-mascot";
8
9
 
9
10
  interface SidebarMascotProps {
10
11
  mascots: Record<string, MascotPack>;
@@ -28,6 +29,9 @@ const DEFAULT_STATE_MAP: Partial<Record<MascotState, string>> = {
28
29
  sleeping: "baozi",
29
30
  };
30
31
 
32
+ const MASCOT_WIDTH = 10;
33
+ const MASCOT_HEIGHT = 5;
34
+
31
35
  export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
32
36
  const names = Object.keys(props.mascots);
33
37
  const initialName =
@@ -36,20 +40,29 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
36
40
  : names[Math.floor(Math.random() * names.length)];
37
41
 
38
42
  const [currentName, setCurrentName] = createSignal(initialName);
39
- const [posX, setPosX] = createSignal(20);
40
- const [posY, setPosY] = createSignal(2);
41
- let dragStartX = 0;
42
- let dragStartY = 0;
43
- let dragAnchorX = 0;
44
- let dragAnchorY = 0;
45
- let lastClickTime = 0;
46
- let isDragging = false;
47
43
 
48
44
  const renderers: Record<string, ReturnType<typeof createAnimatedRenderer>> = {};
49
45
  for (const [name, pack] of Object.entries(props.mascots)) {
50
46
  renderers[name] = createAnimatedRenderer(pack);
51
47
  }
52
48
 
49
+ const switchToNext = () => {
50
+ const cur = currentName();
51
+ const idx = names.indexOf(cur);
52
+ setCurrentName(names[(idx + 1) % names.length]);
53
+ };
54
+
55
+ const { posX, posY, mouseProps, returnToView } = useDraggableMascot({
56
+ initialX: 20,
57
+ initialY: 2,
58
+ mascotWidth: MASCOT_WIDTH,
59
+ mascotHeight: MASCOT_HEIGHT,
60
+ onSwitch: switchToNext,
61
+ clearSelection: () => props.api.renderer.clearSelection(),
62
+ setDragging: (v) => renderers[currentName()].setDragging(v),
63
+ onReturnComplete: () => renderers[currentName()].bounce(),
64
+ });
65
+
53
66
  const switchTo = (name: string) => {
54
67
  if (props.mascots[name] && name !== currentName()) {
55
68
  setCurrentName(name);
@@ -69,11 +82,11 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
69
82
  };
70
83
 
71
84
  props.api.event.on("session.status", (data: unknown) => {
72
- // Plugin receives: { id, type, properties: { sessionID, status: { type } } }
73
85
  const payload = data as { type?: string; properties?: { sessionID?: string; status?: { type?: string } } } | null;
74
86
  const statusType = payload?.properties?.status?.type;
75
87
 
76
88
  if (statusType === "busy" || statusType === "retry") {
89
+ returnToView();
77
90
  renderers[currentName()].setState("busy");
78
91
  } else {
79
92
  setStateWithSwitch("idle");
@@ -113,52 +126,7 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
113
126
  alignItems="center"
114
127
  zIndex={100}
115
128
  flexDirection="column"
116
- onMouseDown={(e: any) => {
117
- const now = Date.now();
118
- if (now - lastClickTime < 300) {
119
- const cur = currentName();
120
- const idx = names.indexOf(cur);
121
- const next = names[(idx + 1) % names.length];
122
- switchTo(next);
123
- lastClickTime = 0;
124
- return;
125
- }
126
- lastClickTime = now;
127
-
128
- if (e.modifiers?.alt) {
129
- dragStartX = e.x;
130
- dragStartY = e.y;
131
- dragAnchorX = posX();
132
- dragAnchorY = posY();
133
- isDragging = true;
134
- renderers[currentName()].setDragging(true);
135
- e.preventDefault();
136
- e.stopPropagation();
137
- props.api.renderer.clearSelection();
138
- }
139
- }}
140
- onMouseDrag={(e: any) => {
141
- if (e.modifiers?.alt && isDragging) {
142
- setPosX(dragAnchorX + (e.x - dragStartX));
143
- setPosY(dragAnchorY + (e.y - dragStartY));
144
- e.preventDefault();
145
- e.stopPropagation();
146
- props.api.renderer.clearSelection();
147
- }
148
- }}
149
- onMouseUp={() => {
150
- if (isDragging) {
151
- isDragging = false;
152
- renderers[currentName()].setDragging(false);
153
- }
154
- }}
155
- onMouseDragEnd={() => {
156
- if (isDragging) {
157
- isDragging = false;
158
- renderers[currentName()].setDragging(false);
159
- }
160
- }}
161
-
129
+ {...mouseProps}
162
130
  >
163
131
  {renderers[currentName()]?.element() ?? null}
164
132
  </box>
@@ -0,0 +1,183 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import { createSignal, onCleanup, type Accessor } from "solid-js";
4
+ import { useTerminalDimensions } from "@opentui/solid";
5
+
6
+ type DragState = "normal" | "edge_hidden" | "returning";
7
+ type HideSide = "left" | "right" | null;
8
+
9
+ export interface MouseProps {
10
+ onMouseDown: (e: any) => void;
11
+ onMouseDrag: (e: any) => void;
12
+ onMouseUp: (e: any) => void;
13
+ onMouseDragEnd: (e: any) => void;
14
+ }
15
+
16
+ interface UseDraggableOpts {
17
+ initialX: number;
18
+ initialY: number;
19
+ mascotWidth: number;
20
+ mascotHeight: number;
21
+ onSwitch: () => void;
22
+ clearSelection: () => void;
23
+ setDragging: (v: boolean) => void;
24
+ onReturnComplete?: () => void;
25
+ peekVisible?: number;
26
+ enableEdge?: boolean;
27
+ }
28
+
29
+ export function useDraggableMascot(opts: UseDraggableOpts): {
30
+ posX: Accessor<number>;
31
+ posY: Accessor<number>;
32
+ mouseProps: MouseProps;
33
+ returnToView: () => void;
34
+ isHidden: () => boolean;
35
+ } {
36
+ const peek = opts.peekVisible ?? 2;
37
+ const enableEdge = opts.enableEdge ?? true;
38
+
39
+ const [posX, setPosX] = createSignal(opts.initialX);
40
+ const [posY, setPosY] = createSignal(opts.initialY);
41
+ const dims = useTerminalDimensions();
42
+
43
+ let dragStartX = 0;
44
+ let dragStartY = 0;
45
+ let dragAnchorX = 0;
46
+ let dragAnchorY = 0;
47
+ let lastClickTime = 0;
48
+ let isDragging = false;
49
+ let state: DragState = "normal";
50
+ let hideSide: HideSide = null;
51
+ let peekTimer: ReturnType<typeof setInterval> | null = null;
52
+ let returnTimer: ReturnType<typeof setInterval> | null = null;
53
+
54
+ const stopPeek = () => {
55
+ if (peekTimer) { clearInterval(peekTimer); peekTimer = null; }
56
+ };
57
+ const stopReturn = () => {
58
+ if (returnTimer) { clearInterval(returnTimer); returnTimer = null; }
59
+ };
60
+
61
+ onCleanup(() => { stopPeek(); stopReturn(); });
62
+
63
+ const clampX = (rawX: number): number => {
64
+ if (!enableEdge) return rawX;
65
+ const { width } = dims();
66
+ const minX = -(opts.mascotWidth - peek);
67
+ const maxX = width - peek;
68
+ return Math.max(minX, Math.min(rawX, maxX));
69
+ };
70
+
71
+ const clampY = (rawY: number): number => {
72
+ if (!enableEdge) return rawY;
73
+ const { height } = dims();
74
+ return Math.max(0, Math.min(rawY, height - opts.mascotHeight));
75
+ };
76
+
77
+ const startPeek = () => {
78
+ stopPeek();
79
+ state = "edge_hidden";
80
+ let phase = false;
81
+ peekTimer = setInterval(() => {
82
+ phase = !phase;
83
+ const stretch = phase ? 2 : 0;
84
+ const { width } = dims();
85
+ if (hideSide === "left") {
86
+ setPosX(-(opts.mascotWidth - peek) + stretch);
87
+ } else if (hideSide === "right") {
88
+ setPosX(width - peek - stretch);
89
+ }
90
+ }, 1200);
91
+ };
92
+
93
+ const returnToView = () => {
94
+ if (state !== "edge_hidden") return;
95
+ stopPeek();
96
+ state = "returning";
97
+ const { width } = dims();
98
+ const cur = posX();
99
+ const targetX = hideSide === "left" ? 0 : Math.max(0, width - opts.mascotWidth);
100
+ const step = targetX > cur ? 2 : -2;
101
+
102
+ returnTimer = setInterval(() => {
103
+ const now = posX();
104
+ if (Math.abs(now - targetX) <= 2) {
105
+ setPosX(targetX);
106
+ stopReturn();
107
+ state = "normal";
108
+ hideSide = null;
109
+ opts.onReturnComplete?.();
110
+ return;
111
+ }
112
+ setPosX(now + step);
113
+ }, 16);
114
+ };
115
+
116
+ const checkEdgeOnRelease = () => {
117
+ if (!enableEdge) return;
118
+ const { width } = dims();
119
+ const x = posX();
120
+ if (x <= -(opts.mascotWidth - peek) + 1) {
121
+ hideSide = "left";
122
+ startPeek();
123
+ } else if (x >= width - peek - 1) {
124
+ hideSide = "right";
125
+ startPeek();
126
+ }
127
+ };
128
+
129
+ const mouseProps: MouseProps = {
130
+ onMouseDown(e: any) {
131
+ if (state === "edge_hidden") {
132
+ returnToView();
133
+ return;
134
+ }
135
+ if (state === "returning") return;
136
+
137
+ const now = Date.now();
138
+ if (now - lastClickTime < 300) {
139
+ opts.onSwitch();
140
+ lastClickTime = 0;
141
+ return;
142
+ }
143
+ lastClickTime = now;
144
+
145
+ if (e.modifiers?.alt) {
146
+ dragStartX = e.x;
147
+ dragStartY = e.y;
148
+ dragAnchorX = posX();
149
+ dragAnchorY = posY();
150
+ isDragging = true;
151
+ opts.setDragging(true);
152
+ e.preventDefault();
153
+ e.stopPropagation();
154
+ opts.clearSelection();
155
+ }
156
+ },
157
+ onMouseDrag(e: any) {
158
+ if (e.modifiers?.alt && isDragging) {
159
+ setPosX(clampX(dragAnchorX + (e.x - dragStartX)));
160
+ setPosY(clampY(dragAnchorY + (e.y - dragStartY)));
161
+ e.preventDefault();
162
+ e.stopPropagation();
163
+ opts.clearSelection();
164
+ }
165
+ },
166
+ onMouseUp() {
167
+ if (isDragging) {
168
+ isDragging = false;
169
+ opts.setDragging(false);
170
+ checkEdgeOnRelease();
171
+ }
172
+ },
173
+ onMouseDragEnd() {
174
+ if (isDragging) {
175
+ isDragging = false;
176
+ opts.setDragging(false);
177
+ checkEdgeOnRelease();
178
+ }
179
+ },
180
+ };
181
+
182
+ return { posX, posY, mouseProps, returnToView, isHidden: () => state === "edge_hidden" };
183
+ }
@@ -48,6 +48,7 @@ export function createAnimatedRenderer(pack: MascotPack): {
48
48
  toggleWalk: () => void;
49
49
  setDragging: (v: boolean) => void;
50
50
  celebrateUpdate: (newVersion: string) => void;
51
+ bounce: () => void;
51
52
  } {
52
53
  const anim = { ...DEFAULT_ANIM, ...pack.animations };
53
54
  const fg = pack.colors?.defaultFg || undefined;
@@ -333,5 +334,13 @@ export function createAnimatedRenderer(pack: MascotPack): {
333
334
  tick();
334
335
  };
335
336
 
336
- return { element, setState, toggleWalk, setDragging, celebrateUpdate };
337
+ const bounce = () => {
338
+ if (currentState() === "sleeping") setState("idle");
339
+ setJumpOffset(-3);
340
+ setTimeout(() => setJumpOffset(-2), 150);
341
+ setTimeout(() => setJumpOffset(-1), 300);
342
+ setTimeout(() => setJumpOffset(0), 450);
343
+ };
344
+
345
+ return { element, setState, toggleWalk, setDragging, celebrateUpdate, bounce };
337
346
  }