Compare commits
1 commit
main
...
op2/bubble
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8648cadc55 |
2 changed files with 88 additions and 18 deletions
|
|
@ -302,6 +302,17 @@ ipcRenderer.on("openpets:pet-scale-override", (_event, scale) => {
|
|||
if (sprite) sprite.style.transform = `scale(${value})`;
|
||||
});
|
||||
|
||||
ipcRenderer.on("openpets:pet-bubble-layout", (_event, layout) => {
|
||||
if (!layout || typeof layout !== "object") return;
|
||||
const root = document.documentElement;
|
||||
if (typeof layout.bubbleMaxHeight === "number") root.style.setProperty("--bubble-max-height", `${layout.bubbleMaxHeight}px`);
|
||||
if (typeof layout.bubbleMaxHeightLong === "number") root.style.setProperty("--bubble-max-height-long", `${layout.bubbleMaxHeightLong}px`);
|
||||
if (typeof layout.bubbleMaxHeightVeryLong === "number") root.style.setProperty("--bubble-max-height-very-long", `${layout.bubbleMaxHeightVeryLong}px`);
|
||||
document.querySelectorAll(".bubble").forEach((bubble) => {
|
||||
bubble.classList.toggle("is-below", layout.bubbleBelow === true);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Plugin audio (named WebAudio recipes + bundled data URLs) ---------------
|
||||
|
||||
let audioContext = null;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import type { ActiveBubble } from "./plugin-bubble-arbiter.js";
|
|||
import type { PluginBubbleIndicator, PluginCommandForm, PluginBubbleHud, PluginBubbleHudItem } from "./plugin-sdk-bridge.js";
|
||||
import { defaultPetSprite, motionToSpriteState, resolveReactionSpriteState, type PetMotionState, type UniversalSpriteState } from "./reaction-animation-mapping.js";
|
||||
|
||||
const petWindowLastDisplay = new WeakMap<BrowserWindow, PetTransientDisplay | null>();
|
||||
|
||||
export interface PetWindowInteractionHooks {
|
||||
readonly onBubbleDismissed?: (dismissToken: string) => void;
|
||||
readonly onBubbleAction?: (dismissToken: string, actionId: string) => void;
|
||||
|
|
@ -431,6 +433,10 @@ function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowI
|
|||
dragging = null;
|
||||
petWindowDragging.set(window, false);
|
||||
debug("pet.window", "drag end", { windowId, position: window.isDestroyed() ? null : readWindowPosition(window) });
|
||||
if (!window.isDestroyed()) {
|
||||
const scale = getAppStateSnapshot().preferences.petScale as PetScaleValue;
|
||||
resizePetWindowForDisplay(window, scale, petWindowLastDisplay.get(window) ?? null);
|
||||
}
|
||||
if (wasDragging) onPetEvent?.("pet:dragEnd", {});
|
||||
};
|
||||
|
||||
|
|
@ -662,6 +668,7 @@ function applyPetAlwaysOnTop(window: BrowserWindow): void {
|
|||
export async function loadDefaultPetContent(window: BrowserWindow, paused: boolean, display: PetTransientDisplay | null = null, badge: PetStatusBadgeReaction | null = null, dismissToken?: string, pluginBubbles: PetPluginBubbles | null = null): Promise<void> {
|
||||
const sequence = allocateWindowLoadSequence(window);
|
||||
const scale = getAppStateSnapshot().preferences.petScale as PetScaleValue;
|
||||
petWindowLastDisplay.set(window, display);
|
||||
resizePetWindowForDisplay(window, scale, display);
|
||||
debug("pet.window", "default content render begin", { windowId: window.id, sequence, paused, hasDisplay: Boolean(display), reaction: display?.reaction, hasMessage: Boolean(display?.message), fullMessage: display?.fullMessage === true, badge, hasPluginBubble: Boolean(pluginBubbles?.transient), hasPinned: Boolean(pluginBubbles?.pinned), defaultPetId: getAppStateSnapshot().preferences.defaultPetId });
|
||||
const render = await createDefaultPetRender(paused, display, badge, dismissToken, pluginBubbles);
|
||||
|
|
@ -669,6 +676,7 @@ export async function loadDefaultPetContent(window: BrowserWindow, paused: boole
|
|||
if (tryUpdateLoadedPetContent(window, render, "default", sequence)) return;
|
||||
await loadPetHtmlFile(window, render.html, "default", sequence).then(() => {
|
||||
petWindowRenderCache.set(window, render.cacheKey);
|
||||
sendBubbleLayout(window, getPetWindowSize(window, scale, display));
|
||||
}).catch((error: unknown) => {
|
||||
logError("pet.window", "default content load failed", error instanceof Error ? error : { error });
|
||||
console.error("Failed to load default pet URL.", error);
|
||||
|
|
@ -677,6 +685,7 @@ export async function loadDefaultPetContent(window: BrowserWindow, paused: boole
|
|||
|
||||
export async function loadExplicitPetContent(window: BrowserWindow, petId: string, display: PetTransientDisplay | null = null, badge: PetStatusBadgeReaction | null = null, dismissToken?: string, scaleOverride?: PetScaleValue, pluginBubbles: PetPluginBubbles | null = null): Promise<void> {
|
||||
const sequence = allocateWindowLoadSequence(window);
|
||||
petWindowLastDisplay.set(window, display);
|
||||
try {
|
||||
const state = getAppStateSnapshot();
|
||||
const pet = state.pets.installed.find((candidate) => candidate.id === petId);
|
||||
|
|
@ -691,6 +700,7 @@ export async function loadExplicitPetContent(window: BrowserWindow, petId: strin
|
|||
if (tryUpdateLoadedPetContent(window, render, `explicit-${pet.id}`, sequence)) return;
|
||||
await loadPetHtmlFile(window, render.html, `explicit-${pet.id}`, sequence);
|
||||
petWindowRenderCache.set(window, render.cacheKey);
|
||||
sendBubbleLayout(window, getPetWindowSize(window, scale, display));
|
||||
} catch (error: unknown) {
|
||||
logError("pet.window", "explicit content load failed", error instanceof Error ? error : { petId, error });
|
||||
console.error(`Failed to load explicit pet ${petId} URL.`, error);
|
||||
|
|
@ -984,7 +994,7 @@ function createPetBodyMarkup(stageLabel: string, bubble: string, spriteMarkup: s
|
|||
</div>`;
|
||||
}
|
||||
|
||||
function createPetWindowCss(paused: boolean, scale: PetScaleValue): string {
|
||||
function createPetWindowCss(paused: boolean, scale: PetScaleValue, layout?: Partial<PetWindowBubbleLayout>): string {
|
||||
const opacity = paused ? "0.62" : "1";
|
||||
const playState = paused ? "paused" : "running";
|
||||
const scaledWidth = Math.ceil(defaultPetSprite.frameWidth * scale);
|
||||
|
|
@ -992,9 +1002,18 @@ function createPetWindowCss(paused: boolean, scale: PetScaleValue): string {
|
|||
const petBottom = 22;
|
||||
const hitPadding = 18;
|
||||
const bubbleBottom = Math.ceil(petBottom + scaledHeight + 8);
|
||||
const bubbleTop = Math.ceil(petBottom + scaledHeight + 8);
|
||||
const bubbleMaxHeight = layout?.bubbleMaxHeight ?? 200;
|
||||
const bubbleMaxHeightLong = layout?.bubbleMaxHeightLong ?? 240;
|
||||
const bubbleMaxHeightVeryLong = layout?.bubbleMaxHeightVeryLong ?? 300;
|
||||
const petShellFilter = process.platform === "win32" ? "none" : "drop-shadow(0 10px 12px rgba(15, 23, 42, 0.24)) drop-shadow(0 2px 3px rgba(15, 23, 42, 0.18))";
|
||||
const bubbleBackdropFilter = process.platform === "win32" ? "none" : "blur(10px)";
|
||||
return `
|
||||
:root {
|
||||
--bubble-max-height: ${bubbleMaxHeight}px;
|
||||
--bubble-max-height-long: ${bubbleMaxHeightLong}px;
|
||||
--bubble-max-height-very-long: ${bubbleMaxHeightVeryLong}px;
|
||||
}
|
||||
:root { color-scheme: dark; --pet-opacity: ${opacity}; --play-state: ${playState}; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; user-select: none; -webkit-font-smoothing: antialiased; }
|
||||
html { color: #172033; }
|
||||
|
|
@ -1002,7 +1021,9 @@ function createPetWindowCss(paused: boolean, scale: PetScaleValue): string {
|
|||
.stage { width: 100%; height: 100%; position: relative; box-sizing: border-box; overflow: visible; }
|
||||
.pet-hitbox { position: absolute; left: 50%; bottom: ${Math.max(0, petBottom - hitPadding)}px; z-index: 1; width: ${scaledWidth + hitPadding * 2}px; height: ${scaledHeight + hitPadding * 2}px; display: grid; place-items: center; transform: translateX(-50%); pointer-events: auto; -webkit-app-region: no-drag; cursor: grab; }
|
||||
.pet-shell { position: relative; width: ${scaledWidth}px; height: ${scaledHeight}px; display: block; opacity: var(--pet-opacity); filter: ${petShellFilter}; transition-property: opacity, filter; transition-duration: 180ms; transition-timing-function: cubic-bezier(0.2, 0, 0, 1); pointer-events: auto; -webkit-app-region: no-drag; cursor: grab; }
|
||||
.bubble { position: absolute; left: 50%; bottom: ${bubbleBottom}px; z-index: 4; box-sizing: border-box; display: inline-flex; flex-direction: column; width: fit-content; min-width: 92px; max-width: min(340px, calc(100vw - 24px)); max-height: 200px; padding: 10px 12px; background: linear-gradient(135deg, rgba(239, 246, 255, 0.97), rgba(237, 233, 254, 0.96)); color: #172033; font: 760 11px/14px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-align: left; border: 1px solid rgba(255, 255, 255, 0.78); border-radius: 14px; box-shadow: 0 12px 24px rgba(15, 23, 42, 0.16), 0 2px 5px rgba(15, 23, 42, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.82); white-space: normal; overflow-wrap: break-word; word-break: normal; overflow: visible; pointer-events: auto; -webkit-app-region: no-drag; opacity: 1; backdrop-filter: ${bubbleBackdropFilter}; transform: translateX(-50%); transform-origin: 64% 100%; animation: bubble-in 180ms cubic-bezier(0.2, 0, 0, 1); }
|
||||
.bubble { position: absolute; left: 50%; bottom: ${bubbleBottom}px; z-index: 4; box-sizing: border-box; display: inline-flex; flex-direction: column; width: fit-content; min-width: 92px; max-width: min(340px, calc(100vw - 24px)); max-height: var(--bubble-max-height); padding: 10px 12px; background: linear-gradient(135deg, rgba(239, 246, 255, 0.97), rgba(237, 233, 254, 0.96)); color: #172033; font: 760 11px/14px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-align: left; border: 1px solid rgba(255, 255, 255, 0.78); border-radius: 14px; box-shadow: 0 12px 24px rgba(15, 23, 42, 0.16), 0 2px 5px rgba(15, 23, 42, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.82); white-space: normal; overflow-wrap: break-word; word-break: normal; overflow: visible; pointer-events: auto; -webkit-app-region: no-drag; opacity: 1; backdrop-filter: ${bubbleBackdropFilter}; transform: translateX(-50%); transform-origin: 64% 100%; animation: bubble-in 180ms cubic-bezier(0.2, 0, 0, 1); }
|
||||
.bubble.is-below { bottom: auto; top: ${bubbleTop}px; transform-origin: 64% 0; }
|
||||
.bubble.is-below::after { top: -7px; bottom: auto; border: none; border-top: 1px solid rgba(255, 255, 255, 0.56); border-left: 1px solid rgba(255, 255, 255, 0.56); border-bottom-right-radius: 0; border-top-left-radius: 3px; transform: translateX(-50%) rotate(45deg); }
|
||||
.bubble[data-dismiss-token] { cursor: pointer; }
|
||||
.bubble::after { content: ""; position: absolute; left: 64%; bottom: -7px; width: 12px; height: 12px; background: inherit; border-right: 1px solid rgba(255, 255, 255, 0.56); border-bottom: 1px solid rgba(255, 255, 255, 0.56); border-bottom-right-radius: 3px; transform: translateX(-50%) rotate(45deg); box-shadow: 3px 3px 7px rgba(15, 23, 42, 0.08); }
|
||||
.bubble-header { display: inline-flex; align-items: center; min-width: 0; gap: 7px; color: currentColor; font: 780 11px/14px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0.01em; }
|
||||
|
|
@ -1019,9 +1040,9 @@ function createPetWindowCss(paused: boolean, scale: PetScaleValue): string {
|
|||
.bubble.is-status-only .bubble-header { display: grid; grid-template-columns: 18px minmax(0, auto); align-items: center; justify-content: center; }
|
||||
.bubble.is-message-only { border-radius: 14px 14px 3px 14px; }
|
||||
.bubble.has-actions { min-width: min(176px, calc(100vw - 18px)); }
|
||||
.bubble.is-long-message { max-width: min(380px, calc(100vw - 24px)); max-height: 240px; }
|
||||
.bubble.is-long-message { max-width: min(380px, calc(100vw - 24px)); max-height: var(--bubble-max-height-long); }
|
||||
.bubble.is-long-message .bubble-text { font-size: 10px; line-height: 13px; }
|
||||
.bubble.is-very-long-message { max-width: min(440px, calc(100vw - 24px)); max-height: 300px; }
|
||||
.bubble.is-very-long-message { max-width: min(440px, calc(100vw - 24px)); max-height: var(--bubble-max-height-very-long); }
|
||||
.bubble.is-very-long-message .bubble-text { font-size: 9.5px; line-height: 12.5px; }
|
||||
.bubble.is-busy .bubble-status-icon { background: #3b82f6; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(59, 130, 246, 0.34); }
|
||||
.bubble.is-waiting .bubble-status-icon { background: #f59e0b; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(245, 158, 11, 0.34); }
|
||||
|
|
@ -1438,19 +1459,27 @@ function allocateWindowLoadSequence(window: BrowserWindow): number {
|
|||
return sequence;
|
||||
}
|
||||
|
||||
function sendBubbleLayout(window: BrowserWindow, layout: PetWindowBubbleLayout): void {
|
||||
if (window.isDestroyed()) return;
|
||||
window.webContents.send("openpets:pet-bubble-layout", {
|
||||
bubbleMaxHeight: layout.bubbleMaxHeight,
|
||||
bubbleMaxHeightLong: layout.bubbleMaxHeightLong,
|
||||
bubbleMaxHeightVeryLong: layout.bubbleMaxHeightVeryLong,
|
||||
bubbleBelow: layout.bubbleBelow,
|
||||
});
|
||||
}
|
||||
|
||||
function resizePetWindowForDisplay(window: BrowserWindow, scale: PetScaleValue, display: PetTransientDisplay | null): void {
|
||||
if (window.isDestroyed()) return;
|
||||
|
||||
const nextSize = getPetWindowSize(window, scale, display);
|
||||
const currentBounds = window.getBounds();
|
||||
if (currentBounds.width === nextSize.width && currentBounds.height === nextSize.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = readWindowPosition(window);
|
||||
const nextY = nextSize.bubbleBelow
|
||||
? anchor.y
|
||||
: anchor.y - Math.max(0, nextSize.height - defaultPetWindowSize.height);
|
||||
const nextBounds = {
|
||||
x: anchor.x - Math.round((nextSize.width - defaultPetWindowSize.width) / 2),
|
||||
y: anchor.y - Math.max(0, nextSize.height - defaultPetWindowSize.height),
|
||||
y: nextY,
|
||||
width: nextSize.width,
|
||||
height: nextSize.height,
|
||||
};
|
||||
|
|
@ -1467,6 +1496,7 @@ function resizePetWindowForDisplay(window: BrowserWindow, scale: PetScaleValue,
|
|||
width: nextSize.width,
|
||||
height: nextSize.height,
|
||||
}, false);
|
||||
sendBubbleLayout(window, nextSize);
|
||||
}
|
||||
|
||||
function resizePetWindowForScale(window: BrowserWindow, scale: PetScaleValue): void {
|
||||
|
|
@ -1498,13 +1528,26 @@ function resizePetWindowForScale(window: BrowserWindow, scale: PetScaleValue): v
|
|||
}, false);
|
||||
}
|
||||
|
||||
function getPetWindowSize(window: BrowserWindow, scale: PetScaleValue, display: PetTransientDisplay | null): { readonly width: number; readonly height: number } {
|
||||
interface PetWindowBubbleLayout {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly bubbleBelow: boolean;
|
||||
readonly bubbleMaxHeight: number;
|
||||
readonly bubbleMaxHeightLong: number;
|
||||
readonly bubbleMaxHeightVeryLong: number;
|
||||
}
|
||||
|
||||
function getPetWindowSize(window: BrowserWindow, scale: PetScaleValue, display: PetTransientDisplay | null): PetWindowBubbleLayout {
|
||||
const base = getBasePetWindowSize(scale);
|
||||
const defaultMaxHeight = 200;
|
||||
const defaultMaxHeightLong = 240;
|
||||
const defaultMaxHeightVeryLong = 300;
|
||||
const message = display?.message ?? display?.reactionMessage ?? "";
|
||||
if (!message) {
|
||||
return base;
|
||||
return { width: base.width, height: base.height, bubbleBelow: false, bubbleMaxHeight: defaultMaxHeight, bubbleMaxHeightLong: defaultMaxHeightLong, bubbleMaxHeightVeryLong: defaultMaxHeightVeryLong };
|
||||
}
|
||||
|
||||
const anchor = readWindowPosition(window);
|
||||
const displayForBounds = screen.getDisplayMatching(window.getBounds()) ?? screen.getPrimaryDisplay();
|
||||
const workArea = displayForBounds.workArea;
|
||||
const scaledHeight = Math.ceil(defaultPetSprite.frameHeight * scale);
|
||||
|
|
@ -1512,6 +1555,16 @@ function getPetWindowSize(window: BrowserWindow, scale: PetScaleValue, display:
|
|||
const bubbleBottom = Math.ceil(petBottom + scaledHeight + 8);
|
||||
const petFootprintExtra = Math.max(0, Math.ceil((scale - 1) * 44));
|
||||
|
||||
const chooseDirection = (neededExtra: number): { readonly bubbleBelow: boolean; readonly available: number } => {
|
||||
const spaceAbove = Math.max(0, anchor.y - workArea.y);
|
||||
const spaceBelow = Math.max(0, workArea.y + workArea.height - (anchor.y + base.height));
|
||||
if (neededExtra <= spaceAbove) return { bubbleBelow: false, available: spaceAbove };
|
||||
if (neededExtra <= spaceBelow) return { bubbleBelow: true, available: spaceBelow };
|
||||
return spaceBelow >= spaceAbove
|
||||
? { bubbleBelow: true, available: spaceBelow }
|
||||
: { bubbleBelow: false, available: spaceAbove };
|
||||
};
|
||||
|
||||
if (display?.fullMessage) {
|
||||
const width = clampNumber(
|
||||
Math.round(340 + Math.min(220, Math.max(0, message.length - 80)) * 1.05),
|
||||
|
|
@ -1521,23 +1574,29 @@ function getPetWindowSize(window: BrowserWindow, scale: PetScaleValue, display:
|
|||
const charsPerLine = Math.max(28, Math.floor((width - 56) / 7.2));
|
||||
const estimatedLines = estimateWrappedLineCount(message, charsPerLine);
|
||||
const bubbleHeight = 74 + estimatedLines * 16 + petFootprintExtra;
|
||||
const neededExtra = Math.max(0, Math.round(bubbleHeight - 132));
|
||||
const { bubbleBelow, available } = chooseDirection(neededExtra);
|
||||
const height = clampNumber(
|
||||
Math.round(base.height + bubbleHeight - 132),
|
||||
Math.max(base.height, base.height + Math.min(neededExtra, available)),
|
||||
base.height,
|
||||
Math.max(base.height, workArea.height - 28),
|
||||
);
|
||||
return { width, height };
|
||||
const maxHeight = Math.max(120, height - bubbleBottom - 8);
|
||||
return { width, height, bubbleBelow, bubbleMaxHeight: maxHeight, bubbleMaxHeightLong: maxHeight, bubbleMaxHeightVeryLong: maxHeight };
|
||||
}
|
||||
|
||||
// Normal message: base width, ensure window is tall enough for the scrollable bubble
|
||||
const bubbleMaxHeight = 168;
|
||||
const minHeightForBubble = bubbleBottom + bubbleMaxHeight + 8;
|
||||
const neededExtra = Math.max(0, bubbleBottom + defaultMaxHeight + 8 - base.height);
|
||||
const { bubbleBelow, available } = chooseDirection(neededExtra);
|
||||
const maxHeight = Math.max(80, Math.min(defaultMaxHeight, available - bubbleBottom - 8));
|
||||
const height = clampNumber(
|
||||
Math.max(base.height, minHeightForBubble),
|
||||
bubbleBelow ? base.height + available : bubbleBottom + maxHeight + 8,
|
||||
base.height,
|
||||
Math.max(base.height, workArea.height - 28),
|
||||
);
|
||||
return { width: base.width, height };
|
||||
const longMax = Math.max(80, Math.min(defaultMaxHeightLong, maxHeight + 40));
|
||||
const veryLongMax = Math.max(80, Math.min(defaultMaxHeightVeryLong, maxHeight + 100));
|
||||
return { width: base.width, height, bubbleBelow, bubbleMaxHeight: maxHeight, bubbleMaxHeightLong: longMax, bubbleMaxHeightVeryLong: veryLongMax };
|
||||
}
|
||||
|
||||
function estimateWrappedLineCount(message: string, charsPerLine: number): number {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue