diff --git a/docs/content/en/api-reference/editor/page.mdx b/docs/content/en/api-reference/editor/page.mdx
index 700896ebe..950ce355e 100644
--- a/docs/content/en/api-reference/editor/page.mdx
+++ b/docs/content/en/api-reference/editor/page.mdx
@@ -11,7 +11,7 @@ description: Atomic editor document reads, commits, and change events
document.
-`dmn.editor` reads and updates the six collections that make up the editor
+`dmn.editor` reads and updates the seven collections that make up the editor
layout as one revisioned document. Use it when one user action changes more
than one collection, or when a consumer needs a single ordered change stream.
@@ -24,6 +24,7 @@ type EditorField =
| 'statPositions'
| 'graphPositions'
| 'knobPositions'
+ | 'spritePositions'
| 'layerGroups';
interface EditorDocumentV1 {
@@ -33,6 +34,7 @@ interface EditorDocumentV1 {
statPositions: StatItemPositions;
graphPositions: GraphItemPositions;
knobPositions: KnobItemPositions;
+ spritePositions: SpritePositions;
layerGroups: LayerGroups;
}
@@ -47,22 +49,127 @@ that top-level collection. It is not an item-level diff.
Since the multi-key update, `keys` entries in `EditorDocumentV1` (returned by
`get()`) and in `EditorPatchV1` (delivered by `onCommitted()`) use the
- `KeySlot` union (`string | MultiKeySlot`), not plain strings. See the
- [Keys API](/docs/api-reference/keys) for the slot shape and canonical
- identifier rules.
+ `KeySlot` union (`string | MultiKeySlot`), not plain strings. See the [Keys
+ API](/docs/api-reference/keys) for the slot shape and canonical identifier
+ rules.
Every element in the position collections (`keyPositions`, `statPositions`,
- `graphPositions`, `knobPositions`) carries a stable `id` (UUID) assigned and
- owned by the app. Treat it as opaque: echo back the value you read and never
- invent one. A written element with a missing or unknown `id` gets a fresh one
- from the backend. If the same `id` appears more than once, for example when
- you copy an element you read and append it, the element in its original slot
- keeps the identity and the remaining copies get fresh ones. See the
- [Keys API](/docs/api-reference/keys) for the identity rules.
+ `graphPositions`, `knobPositions`, `spritePositions`) carries a stable `id`
+ (UUID) assigned and owned by the app. Treat it as opaque: echo back the value
+ you read and never invent one. A written element whose `id` is missing or
+ unknown inherits the `id` of an existing element whose remaining fields match;
+ with no match, the backend assigns a fresh one. Omitting an `id` does not
+ guarantee a fresh identity, and editing an element's values while omitting its
+ `id` may give it one. If the same `id` appears more than once, for example
+ when you copy an element you read and append it, the element in its original
+ slot keeps the identity and the remaining copies get fresh ones. The `poseId`
+ carried by each pose of a sprite element follows the same rules. See the [Keys
+ API](/docs/api-reference/keys) for the identity rules.
+## Sprite Position Types
+
+Reactive sprites are image layers that switch poses while their trigger keys
+are held. Unlike key elements they are not index-coupled to key mappings; each
+pose references the responsible key element ids directly. Documents you read
+always carry `id` and `poseId`; echo both back when you write.
+The collection and value limits noted below are enforced when you commit;
+documents you read echo stored data as is.
+
+```typescript
+// Keyed by tab/mode id (e.g., "4key")
+type SpritePositions = Record;
+
+interface ReactiveSpritePosition {
+ id?: string; // stable element identity (UUID)
+ dx: number; // element box origin and size. The box is the base image box and feeds overlay window sizing
+ dy: number;
+ width: number;
+ height: number;
+ rotation?: number; // placement rotation around the element-box center, -180–180 deg, default 0
+ hidden: boolean;
+ zIndex: number | null;
+ layerName?: string | null; // omitted by the backend when unset
+ groupId?: string | null; // omitted by the backend when unset
+ className: string | null;
+ useInlineStyles: boolean | null;
+ baseImage: string | null; // image shown when no pose matches; always fills the element box
+ referenceNaturalSize: {
+ source: string | null;
+ width: number;
+ height: number;
+ } | null; // intrinsic pixel size that defines the pixel scale (integers, 1–32768). source must equal baseImage when one is set and be null otherwise; null falls back to drawing every image in the element box
+ pivot: { x: number; y: number }; // normalized shared axis, 0–1 each, default {0.5, 0.5}. Base-image pivot and transform origin for every pose
+ idleTransform: SpriteTransform; // base transform when no pose matches
+ poses: SpritePose[]; // commits accept at most 64
+ activation: 'whileHeld' | 'onPress'; // default whileHeld. onPress plays the pose once on key-down, then returns to idle
+ pressDurationMs: number; // onPress playback duration, 1–5000, default 300
+ transitionMs: number; // pose transition duration, 0–1000, default 0. Ignored by onPress, which plays for pressDurationMs
+ transitionEasing: string; // CSS easing, default 'cubic-bezier(0.4, 0, 0.2, 1)'
+}
+
+interface SpritePose {
+ poseId?: string; // stable pose identity (UUID)
+ name?: string | null; // omitted by the backend when unset
+ triggers: string[]; // key element ids; commits accept at most 512, sorted and deduplicated
+ transform: SpriteTransform;
+ pivot?: { x: number; y: number } | null; // independent pivot within the pose image; null or omission follows the sprite's shared pivot
+ imageOverride: string | null; // replaces baseImage while this pose is active
+ imageOverrideMetrics: {
+ source: string;
+ width: number;
+ height: number;
+ } | null; // intrinsic pixel size of imageOverride (integers, 1–32768). Optional; when set, source must equal the path exactly. Normalized to null when imageOverride is absent
+}
+
+interface SpriteTransform {
+ x: number; // -2000–2000 px
+ y: number; // -2000–2000 px
+ rotation: number; // -180–180 deg
+ scale: number; // 0.1–10
+}
+```
+
+The element-level `rotation` rotates the entire sprite, including all poses,
+around the center of its logical box. It is independent of
+`idleTransform.rotation` and each pose's `transform.rotation`, which remain
+local rotations around the shared pivot. Rotating a selection changes the
+sprite's `dx`, `dy`, and element-level `rotation` together; pose transforms,
+triggers, image references, and transition directions remain unchanged.
+New sprites default to 0 when `rotation` is omitted. For an existing sprite,
+schemaVersion 1 and 2 commits preserve its stored rotation when the field is
+omitted. `null`, non-finite values, and values outside `-180..180` are rejected.
+
+A pose activates while the set of held keys, among the keys the sprite
+references, exactly matches its trigger set; keys the sprite does not reference
+are ignored. When no pose matches exactly, the single-trigger poses of the held
+keys are blended with equal weight: x, y and scale are averaged, rotation uses a
+circular mean, and the image comes from the first override in poseId order.
+Poses with two or more triggers never take part in blending, so a sprite whose
+held keys only appear in such poses returns to idle. Two poses of one sprite may
+not share the same trigger set, and commits reject empty trigger lists.
+
+The element box is the base-image box. The base image always fills it, and the
+shared axis P is the sprite pivot × (width, height). A pose whose pivot is null
+or omitted uses the same normalized point. A pose with a pivot value aligns that
+independent point within its image to P. A pose image with a different intrinsic
+size is drawn at intrinsic pixels × the reference scale, where the reference
+scale is (width, height) ÷ referenceNaturalSize per axis. Without a reference
+size or the pose image's own metrics, the image uses the element-box size.
+Moving the shared pivot keeps the base image in place while linked poses follow
+the new axis. Poses with an independent pivot keep their screen position by
+compensating x/y in the same commit. Moving a pose pivot or switching the link
+also compensates x/y to avoid a sudden image jump. An out-of-range pivot
+rejects the commit with `VALIDATION_FAILED` and a `details.validationCode` of
+`INVALID_SPRITE_PIVOT` or `INVALID_SPRITE_POSE_PIVOT`. Invalid image metrics use
+`STALE_SPRITE_IMAGE_METRICS` or `INVALID_SPRITE_IMAGE_METRICS`. A schemaVersion 1
+patch that omits `pivot`, `referenceNaturalSize`, `imageOverrideMetrics`,
+`activation`, or `pressDurationMs` on an existing element or pose keeps the
+stored value. Sending null for a pose pivot links it to the shared pivot.
+Changing `baseImage` or `imageOverride` while omitting its metrics clears them.
+
## Read the Current Document
### `dmn.editor.get(): Promise`
@@ -94,10 +201,10 @@ platform, while no application can guarantee recovery from hardware or
firmware that violates those barriers.
- The app's Undo/Redo path also restores the six editor collections, custom-tab
- metadata, selected mode, counters, preset settings, and per-tab note settings
- in one backend store transaction. This internal command is intentionally not
- part of the public plugin API.
+ The app's Undo/Redo path also restores the seven editor collections,
+ custom-tab metadata, selected mode, counters, preset settings, and per-tab
+ note settings in one backend store transaction. This internal command is
+ intentionally not part of the public plugin API.
```typescript
@@ -218,19 +325,19 @@ interface EditorCommitError {
}
```
-| Code | Meaning | `retryable` |
-| ------------------------ | ------------------------------------------------------------------------- | ----------- |
-| `REVISION_CONFLICT` | `baseRevision` is stale; read, reconcile, and commit again | `true` |
-| `PLUGIN_REVISION_CONFLICT` | The plugin-scoped revision is stale; read, reconcile, and commit again | `true` |
-| `VALIDATION_FAILED` | The completed document violates an editor validation rule | `false` |
-| `TOO_MANY_GESTURE_IDS` | `gestureIds` exceeds 32 entries or the combined unique set exceeds 32 IDs | `false` |
-| `INVALID_GESTURE_ID` | A gesture ID is not a UUID within the 64-byte limit | `false` |
-| `PAIRED_UPDATE_REQUIRED` | A structural key change omitted its paired collection | `false` |
-| `MULTI_KEY_UNSUPPORTED` | A `keys` write did not declare `multiKey` while multi-key slots exist | `false` |
-| `MUTATION_ID_REUSED` | The same mutation ID was used for a different request | `false` |
-| `IO_ERROR` | The document could not be persisted | `true` |
-| `HISTORY_IN_PROGRESS` | An undo/redo barrier is in progress; retry after it settles | `true` |
-| `HISTORY_EPOCH_CONFLICT` | The observed history epoch is stale; re-read and commit again | `true` |
+| Code | Meaning | `retryable` |
+| -------------------------- | ------------------------------------------------------------------------- | ----------- |
+| `REVISION_CONFLICT` | `baseRevision` is stale; read, reconcile, and commit again | `true` |
+| `PLUGIN_REVISION_CONFLICT` | The plugin-scoped revision is stale; read, reconcile, and commit again | `true` |
+| `VALIDATION_FAILED` | The completed document violates an editor validation rule | `false` |
+| `TOO_MANY_GESTURE_IDS` | `gestureIds` exceeds 32 entries or the combined unique set exceeds 32 IDs | `false` |
+| `INVALID_GESTURE_ID` | A gesture ID is not a UUID within the 64-byte limit | `false` |
+| `PAIRED_UPDATE_REQUIRED` | A structural key change omitted its paired collection | `false` |
+| `MULTI_KEY_UNSUPPORTED` | A `keys` write did not declare `multiKey` while multi-key slots exist | `false` |
+| `MUTATION_ID_REUSED` | The same mutation ID was used for a different request | `false` |
+| `IO_ERROR` | The document could not be persisted | `true` |
+| `HISTORY_IN_PROGRESS` | An undo/redo barrier is in progress; retry after it settles | `true` |
+| `HISTORY_EPOCH_CONFLICT` | The observed history epoch is stale; re-read and commit again | `true` |
`details.currentRevision`, `details.validationCode`, `details.field`, or
`details.currentHistoryEpoch` is included when it applies to the error.
diff --git a/docs/content/en/api-reference/keys/page.mdx b/docs/content/en/api-reference/keys/page.mdx
index 9ed70de84..a9e9b8ce7 100644
--- a/docs/content/en/api-reference/keys/page.mdx
+++ b/docs/content/en/api-reference/keys/page.mdx
@@ -337,9 +337,20 @@ gradient siblings:
interface KeyPosition {
id?: string; // stable element identity (UUID), assigned and owned by the app
// ...position, image, note, and counter fields...
+ rotation: number; // degrees, -180 to 180. Defaults to 0. Content rotation about the box center
imageMode?: 'replace' | 'overlay'; // image placement, defaults to replace (image replaces the key)
- idleImageTransform?: { offsetX: number; offsetY: number; rotation: number; scale: number }; // identity when absent
- activeImageTransform?: { offsetX: number; offsetY: number; rotation: number; scale: number };
+ idleImageTransform?: {
+ offsetX: number;
+ offsetY: number;
+ rotation: number;
+ scale: number;
+ }; // identity when absent
+ activeImageTransform?: {
+ offsetX: number;
+ offsetY: number;
+ rotation: number;
+ scale: number;
+ };
noteOpacityTop?: number;
noteOpacityBottom?: number;
noteGradient?: GradientSpec | null;
@@ -387,10 +398,25 @@ interface ElementShadowSpec {
}
```
+`rotation` is the element rotation in degrees. Keys, stats, graphs, and knobs
+share the field. The logical box (`dx`/`dy`/`width`/`height`) stays axis
+aligned while the face, the note track, and the outside counter rotate
+together about the box center. Omitting the field means 0; `null`, non-finite
+values, and values outside `-180..180` are rejected. Automatic hitline
+correction (`noteAutoYCorrection`) aligns unrotated keys to the content top,
+and aligns rotated keys that flow in the same direction (within 0.5°) to the
+top edge that is furthest along that direction. A key rotated on its own
+starts at its own top edge, and note offsets apply after the correction.
+Sprites also have an element-level placement `rotation`, independent of their
+idle and per-pose rotations. See [Sprite Position Types](/docs/api-reference/editor#sprite-position-types).
+
Every element position (`keyPositions`, `statPositions`, `graphPositions`,
-`knobPositions`) carries a stable `id`. Treat it as opaque: echo back the value
-you read and never invent one. A write whose `id` is missing or unknown gets a
-fresh one assigned by the backend. When the same `id` appears more than once,
+`knobPositions`, `spritePositions`) carries a stable `id`. Treat it as opaque: echo back the value
+you read and never invent one. A write whose `id` is missing or unknown
+inherits the `id` of an existing element whose remaining fields match; with no
+match, the backend assigns a fresh one. Omitting an `id` does not guarantee a
+fresh identity, and editing an element's values while omitting its `id` may
+give it one. When the same `id` appears more than once,
for example when you copy an element you read and append it, the element in
its original slot keeps the identity and the remaining copies get fresh ones.
Loading a full preset re-issues every `id`; a tab preset re-issues only the
diff --git a/docs/content/en/api-reference/overlay/page.mdx b/docs/content/en/api-reference/overlay/page.mdx
index 8b821de72..839cef289 100644
--- a/docs/content/en/api-reference/overlay/page.mdx
+++ b/docs/content/en/api-reference/overlay/page.mdx
@@ -64,6 +64,7 @@ interface ResizePayload {
width: number;
height: number;
anchor?: string;
+ contentLeftOffset?: number;
contentTopOffset?: number;
fixedPositionDeltaX?: number;
fixedPositionDeltaY?: number;
diff --git a/docs/content/en/api-reference/presets/page.mdx b/docs/content/en/api-reference/presets/page.mdx
index c525eb2bd..19b3038af 100644
--- a/docs/content/en/api-reference/presets/page.mdx
+++ b/docs/content/en/api-reference/presets/page.mdx
@@ -23,9 +23,9 @@ Saves the current configuration to a preset file. Opens a file save dialog.
```javascript
const result = await dmn.presets.save();
if (result.success) {
- console.log("Preset saved");
+ console.log('Preset saved');
} else {
- console.error("Save failed:", result.error);
+ console.error('Save failed:', result.error);
}
```
@@ -37,9 +37,9 @@ picker dialog.
```javascript
const result = await dmn.presets.load();
if (result.success) {
- console.log("Preset loaded");
+ console.log('Preset loaded');
} else {
- console.error("Load failed:", result.error);
+ console.error('Load failed:', result.error);
}
```
@@ -56,7 +56,10 @@ const result = await dmn.presets.saveTab();
Loads a tab preset file into the currently selected tab. Opens a file picker
dialog. Tab presets carry no `tabOrder` or `barCount`, so they never change the
-global tab order.
+global tab order. Element collections stored in the file (keys, stats, graphs,
+knobs, sprites) replace the tab's contents wholesale, so elements missing from
+the file's tab are removed. Older presets that lack a collection entirely leave
+those elements untouched.
```javascript
const result = await dmn.presets.loadTab();
@@ -80,6 +83,7 @@ interface PresetSnapshot {
statPositions: StatItemPositions;
graphPositions: GraphItemPositions;
knobPositions: KnobItemPositions;
+ spritePositions: SpritePositions;
customTabs: CustomTab[];
tabOrder: string[];
barCount: number;
@@ -88,48 +92,49 @@ interface PresetSnapshot {
}
const unsub = dmn.presets.onSnapshot((snapshot) => {
- console.log("Preset loaded:", snapshot.selectedKeyType);
- console.log("Key mappings:", snapshot.keys);
+ console.log('Preset loaded:', snapshot.selectedKeyType);
+ console.log('Key mappings:', snapshot.keys);
});
```
- Individual change events (`onChanged`, `onPositionsChanged`, etc.) are not emitted during preset load.
- Use `onSnapshot` to detect preset loads.
+ Individual change events (`onChanged`, `onPositionsChanged`, etc.) are not
+ emitted during preset load. Use `onSnapshot` to detect preset loads.
---
+
## Example Usage
```javascript
// @id preset-manager
-if (dmn.window.type !== "main") return;
+if (dmn.window.type !== 'main') return;
// Save preset menu item
dmn.ui.contextMenu.addGridMenuItem({
- id: "save-preset",
- label: "Save Preset",
+ id: 'save-preset',
+ label: 'Save Preset',
onClick: async () => {
const result = await dmn.presets.save();
if (result.success) {
- await dmn.ui.dialog.alert("Preset saved!");
+ await dmn.ui.dialog.alert('Preset saved!');
}
},
});
// Load preset menu item
dmn.ui.contextMenu.addGridMenuItem({
- id: "load-preset",
- label: "Load Preset",
+ id: 'load-preset',
+ label: 'Load Preset',
onClick: async () => {
const confirmed = await dmn.ui.dialog.confirm(
- "This will overwrite the current configuration. Continue?"
+ 'This will overwrite the current configuration. Continue?',
);
if (confirmed) {
const result = await dmn.presets.load();
if (result.success) {
- await dmn.ui.dialog.alert("Preset loaded!");
+ await dmn.ui.dialog.alert('Preset loaded!');
}
}
},
diff --git a/docs/content/en/custom-css/variables/page.mdx b/docs/content/en/custom-css/variables/page.mdx
index 6da3315cd..798b701a1 100644
--- a/docs/content/en/custom-css/variables/page.mdx
+++ b/docs/content/en/custom-css/variables/page.mdx
@@ -186,6 +186,32 @@ resets it together with its spacing.
}
```
+## Sprite Style Variables
+
+Reactive sprites render a single image layer that switches poses while its
+trigger keys are held. Placement and the rotation origin stay inline because the
+window bounds are computed from them; the appearance channels below are the ones
+you can replace. Disable **Inline Styles Priority** on the sprite to use them.
+
+`--sprite-transform` replaces only the image's local pose transform. The
+element-level placement `rotation` remains app-managed around the box center
+and is applied outside that pose transform.
+
+| Variable | Description | Type | Example |
+| --------------------- | -------------------------------------------------------------------------------------- | -------------- | -------------------------- |
+| `--sprite-transform` | Replacement transform for the resolved pose (an onPress sprite then only swaps images) | `` | `rotate(15deg) scale(1.2)` |
+| `--sprite-transition` | Replacement transition for the pose change (whileHeld only) | `` | `transform 120ms ease-out` |
+
+```css
+.left-hand [data-sprite-element] > img {
+ --sprite-transition: transform 90ms cubic-bezier(0.2, 0, 0, 1);
+}
+```
+
+The image element already has the source aspect ratio and fills its box, so
+`object-fit` has nothing to change. Overriding the element size itself voids the
+pivot alignment of pose images.
+
## Selector Reference
### Key Selectors
@@ -225,6 +251,16 @@ resets it together with its spacing.
| `.knobClass [data-knob-element]` | A specific class's knob surface |
| `[data-knob-indicator]` | Center rotation indicator |
+### Sprite Selectors
+
+| Selector | Description |
+| ------------------------------------ | --------------------------------------------------------------- |
+| `[data-sprite-element]` | Every sprite element box |
+| `[data-sprite-element] > img` | The sprite image layer |
+| `[data-sprite-state="inactive"]` | Sprites with no trigger key held |
+| `[data-sprite-state="active"]` | Sprites with a trigger key held (onPress sprites stay inactive) |
+| `.spriteClass [data-sprite-element]` | A specific class's sprite |
+
## Standard CSS Properties
With **Inline Styles Priority** disabled, you can directly use standard visual
diff --git a/docs/content/en/settings/page.mdx b/docs/content/en/settings/page.mdx
index cfce5ba4c..d2251f7a2 100644
--- a/docs/content/en/settings/page.mdx
+++ b/docs/content/en/settings/page.mdx
@@ -66,7 +66,11 @@ const current = pluginSettings.get();
console.log("API Key:", current.apiKey);
// Change settings values
-await pluginSettings.set({ theme: "light" });
+try {
+ await pluginSettings.set({ theme: "light" });
+} catch (error) {
+ console.error("Failed to save settings:", error);
+}
// Open settings panel (default: property panel)
const confirmed = await pluginSettings.open();
@@ -113,6 +117,12 @@ interface PluginSettingsDefinition {
| `reset()` | Reset to defaults |
| `subscribe(listener)` | Subscribe to settings changes |
+`set()` and `reset()` reject their promises if saving fails. The failed edit is
+restored from saved settings without overwriting a newer edit started during
+recovery. Change callbacks and subscriber notifications run only after a successful
+save. A property panel save failure shows an error and resolves `open()` to `false`;
+a modal save failure rejects `open()`.
+
## onChange vs subscribe()
Both detect settings changes but have different purposes:
diff --git a/docs/content/ko/api-reference/editor/page.mdx b/docs/content/ko/api-reference/editor/page.mdx
index 58be2949b..d2dfcdae7 100644
--- a/docs/content/ko/api-reference/editor/page.mdx
+++ b/docs/content/ko/api-reference/editor/page.mdx
@@ -11,7 +11,7 @@ description: 에디터 문서의 원자적 조회, 커밋, 변경 이벤트 API
있습니다.
-`dmn.editor`는 에디터 레이아웃을 이루는 여섯 컬렉션을 revision이 있는 문서
+`dmn.editor`는 에디터 레이아웃을 이루는 일곱 컬렉션을 revision이 있는 문서
하나로 조회하고 갱신합니다. 사용자 동작 하나가 여러 컬렉션을 함께 바꾸거나,
소비자가 순서가 보장된 변경 흐름 하나를 받아야 할 때 사용하세요.
@@ -24,6 +24,7 @@ type EditorField =
| 'statPositions'
| 'graphPositions'
| 'knobPositions'
+ | 'spritePositions'
| 'layerGroups';
interface EditorDocumentV1 {
@@ -33,6 +34,7 @@ interface EditorDocumentV1 {
statPositions: StatItemPositions;
graphPositions: GraphItemPositions;
knobPositions: KnobItemPositions;
+ spritePositions: SpritePositions;
layerGroups: LayerGroups;
}
@@ -53,14 +55,114 @@ type EditorPatchV1 = {
위치 컬렉션(`keyPositions`, `statPositions`, `graphPositions`,
- `knobPositions`)의 모든 요소는 앱이 발급·소유하는 안정 `id`(UUID)를
- 가집니다. 불투명 값으로 다루세요. 읽은 값을 그대로 되돌려 보내고, 직접
- 만들지 마세요. `id`가 없거나 미확인인 요소를 쓰면 백엔드가 새 값을
- 발급합니다. 읽은 요소를 복사해 배열에 덧붙이는 경우처럼 같은 `id`가 여러 번
- 나오면, 원래 자리의 요소가 신원을 지키고 나머지 사본이 새 값을 받습니다.
+ `knobPositions`, `spritePositions`)의 모든 요소는 앱이 발급·소유하는 안정
+ `id`(UUID)를 가집니다. 불투명 값으로 다루세요. 읽은 값을 그대로 되돌려 보내고,
+ 직접 만들지 마세요. `id`가 없거나 미확인인 요소를 쓰면 나머지 필드가 같은 기존
+ 요소의 신원을 승계합니다. 맞는 항목이 없으면 백엔드가 새 값을 발급합니다.
+ `id`를 빼는 것이 새 신원을 보장하지는 않고, 값을 바꾸면서 `id`까지 빼면 새
+ 신원이 될 수 있습니다. 읽은 요소를 복사해 배열에 덧붙이는 경우처럼 같은 `id`가
+ 여러 번 나오면, 원래 자리의 요소가 신원을 지키고 나머지 사본이 새 값을
+ 받습니다. 스프라이트 요소의 각 자세가 가진 `poseId`도 같은 규칙을 따릅니다.
신원 규칙은 [Keys API](/docs/api-reference/keys)를 참고하세요.
+## 스프라이트 위치 타입
+
+반응형 스프라이트는 담당 키를 누르고 있는 동안 자세가 바뀌는 이미지 레이어입니다.
+키 요소와 달리 키 매핑에 인덱스로 결합하지 않고, 자세마다 담당 키 요소 id를 직접
+참조합니다. 읽은 문서에는 `id`와 `poseId`가 항상 실려 있습니다. 쓸 때는 둘 다
+그대로 돌려보내세요. 아래 표기된 개수·값 상한은 커밋 시점에
+검증하고, 읽은 문서는 저장된 데이터를 그대로 되돌려줍니다.
+
+```typescript
+// 탭/모드 id(예: "4key")로 키잉
+type SpritePositions = Record;
+
+interface ReactiveSpritePosition {
+ id?: string; // 안정 요소 신원 (UUID)
+ dx: number; // 요소 상자 원점과 크기. 상자가 곧 기본 이미지 상자이고 오버레이 창 크기 계산에 들어갑니다
+ dy: number;
+ width: number;
+ height: number;
+ rotation?: number; // 요소 상자 중심을 축으로 하는 배치 회전, -180–180도, 기본 0
+ hidden: boolean;
+ zIndex: number | null;
+ layerName?: string | null; // 미설정이면 백엔드가 키를 생략합니다
+ groupId?: string | null; // 미설정이면 백엔드가 키를 생략합니다
+ className: string | null;
+ useInlineStyles: boolean | null;
+ baseImage: string | null; // 일치하는 자세가 없을 때 보여줄 이미지. 항상 요소 상자를 채웁니다
+ referenceNaturalSize: {
+ source: string | null;
+ width: number;
+ height: number;
+ } | null; // 픽셀 배율의 기준이 되는 원본 크기(정수, 1–32768). baseImage가 있으면 source가 그 경로와 같아야 하고 없으면 null이어야 합니다. null이면 모든 이미지를 요소 상자에 그대로 그립니다
+ pivot: { x: number; y: number }; // 정규화 공통 축, 각각 0–1, 기본 {0.5, 0.5}. 기본 이미지의 기준점이자 모든 자세 변환의 축
+ idleTransform: SpriteTransform; // 일치하는 자세가 없을 때의 기본 변환
+ poses: SpritePose[]; // 커밋은 최대 64개까지 허용
+ activation: 'whileHeld' | 'onPress'; // 기본 whileHeld. onPress는 누른 순간 자세를 한 번 재생하고 idle로 복귀
+ pressDurationMs: number; // onPress 재생 시간, 1–5000, 기본 300
+ transitionMs: number; // 자세 전환 시간, 0–1000, 기본 0. onPress는 무시하고 pressDurationMs 동안 재생
+ transitionEasing: string; // CSS easing, 기본 'cubic-bezier(0.4, 0, 0.2, 1)'
+}
+
+interface SpritePose {
+ poseId?: string; // 안정 자세 신원 (UUID)
+ name?: string | null; // 미설정이면 백엔드가 키를 생략합니다
+ triggers: string[]; // 키 요소 id 목록, 커밋은 최대 512개까지 허용하며 정렬·중복 제거
+ transform: SpriteTransform;
+ pivot?: { x: number; y: number } | null; // 상태 이미지의 독립 기준점. null 또는 생략이면 스프라이트의 공통 기준점을 따라갑니다
+ imageOverride: string | null; // 이 자세가 활성인 동안 baseImage를 대체
+ imageOverrideMetrics: {
+ source: string;
+ width: number;
+ height: number;
+ } | null; // imageOverride의 원본 픽셀 크기(정수, 1–32768). 선택 사항이고, 있으면 source가 경로와 정확히 같아야 합니다. imageOverride가 없으면 null로 정규화됩니다
+}
+
+interface SpriteTransform {
+ x: number; // -2000–2000 px
+ y: number; // -2000–2000 px
+ rotation: number; // -180–180 도
+ scale: number; // 0.1–10
+}
+```
+
+요소의 `rotation`은 논리 상자의 중심을 축으로 모든 자세를 포함한 스프라이트
+전체를 돌립니다. 공통 기준점을 축으로 도는 로컬 회전인 `idleTransform.rotation`,
+각 자세의 `transform.rotation`과는 독립적입니다. 선택 전체를 회전하면
+스프라이트의 `dx`, `dy`, 요소 `rotation`을 함께 바꾸며 자세 변환, 트리거,
+이미지 참조와 자세 전환 방향은 유지됩니다.
+새 스프라이트에서 `rotation`을 생략하면 0입니다. 이미 있는 스프라이트의
+schemaVersion 1·2 커밋에서는 생략 시 저장된 회전값을 유지합니다.
+`null`, 비유한값, `-180~180` 밖의 값은 거절됩니다.
+
+자세는 스프라이트가 참조하는 키 중 현재 눌린 키 집합이 트리거 집합과 정확히
+일치할 때 활성화되고, 스프라이트가 참조하지 않는 키는 무시합니다. 정확히
+일치하는 자세가 없으면 눌린 키 각각의 단일 트리거 자세를 같은 비중으로
+섞습니다. x·y·scale은 산술 평균, rotation은 원형 평균이고 이미지는 poseId
+사전순 첫 override를 씁니다. 트리거가 둘 이상인 자세는 섞기에 참여하지 않으므로
+눌린 키가 그런 자세에만 있으면 idle로 돌아갑니다. 한 스프라이트의 두 자세가 같은
+트리거 집합을 가질 수 없고, 빈 트리거 목록은 커밋에서 거부됩니다.
+
+요소 상자가 곧 기본 이미지 상자입니다. 기본 이미지는 항상 상자를 채우고, 공통 축 P는
+스프라이트의 pivot × (width, height)입니다. 상태의 pivot이 null이거나 생략되면 상태
+이미지도 같은 정규화 기준점을 사용합니다. 상태에 pivot 값이 있으면 그 상태 이미지의
+독립 기준점을 P에 맞춥니다. 원본 크기가 다른 상태 이미지는 원본 픽셀 × 기준 배율로
+그립니다. 기준 배율은 (width, height)를 referenceNaturalSize로 축별로 나눈 값입니다.
+기준 크기나 상태 이미지의 크기가 없으면 그 이미지를 요소 상자 크기로 그립니다.
+에디터에서 공통 기준점을 옮기면 기본 이미지는 제자리에 남고, 공통 기준점에 연결된
+상태는 새 축을 따라갑니다. 독립 기준점을 가진 상태는 x/y를 같은 커밋에서 보정해
+화면 위치를 유지합니다. 상태 기준점을 옮기거나 연결을 전환할 때도 상태 이미지가
+갑자기 움직이지 않도록 x/y를 보정합니다. 범위를 벗어난 기준점은 커밋이
+`VALIDATION_FAILED`로 거절되며 `details.validationCode`는 `INVALID_SPRITE_PIVOT`
+또는 `INVALID_SPRITE_POSE_PIVOT`입니다. 원본 크기가 경로와 어긋나거나 범위를 벗어나면
+`STALE_SPRITE_IMAGE_METRICS` 또는 `INVALID_SPRITE_IMAGE_METRICS`입니다.
+schemaVersion 1 패치에서 이미 있는 요소·자세의 `pivot`, `referenceNaturalSize`,
+`imageOverrideMetrics`, `activation`, `pressDurationMs`를 생략하면 저장된 값이
+유지됩니다. 상태의 `pivot`을 null로 보내면 공통 기준점 연결로 바뀝니다. 다만
+`baseImage`나 `imageOverride`를 바꾸면서 크기를 생략하면 크기는 비워집니다.
+
## 현재 문서 조회
### `dmn.editor.get(): Promise`
@@ -92,7 +194,7 @@ console.log(revision, document.keyPositions);
고장까지 애플리케이션이 절대 보장할 수는 없습니다.
- 앱의 Undo/Redo도 편집 컬렉션 6개, 커스텀 탭 정보, 선택 모드, 카운터, 프리셋
+ 앱의 Undo/Redo도 편집 컬렉션 7개, 커스텀 탭 정보, 선택 모드, 카운터, 프리셋
설정, 탭별 노트 설정을 백엔드 store 트랜잭션 한 번으로 함께 복원합니다. 이
내부 커맨드는 공개 플러그인 API로 노출하지 않습니다.
@@ -212,19 +314,19 @@ interface EditorCommitError {
}
```
-| 코드 | 의미 | `retryable` |
-| ------------------------ | --------------------------------------------------------- | ----------- |
-| `REVISION_CONFLICT` | `baseRevision`이 오래됨. 다시 조회·조정한 뒤 커밋 | `true` |
-| `PLUGIN_REVISION_CONFLICT` | 플러그인 범위 revision이 오래됨. 다시 조회한 뒤 커밋 | `true` |
-| `VALIDATION_FAILED` | 완성된 문서가 에디터 검증 규칙을 위반함 | `false` |
-| `TOO_MANY_GESTURE_IDS` | `gestureIds`가 32개를 넘거나 합친 고유 ID가 32개를 초과함 | `false` |
-| `INVALID_GESTURE_ID` | gesture ID가 UUID가 아니거나 64바이트를 초과함 | `false` |
-| `PAIRED_UPDATE_REQUIRED` | 키 구조 변경에 짝 컬렉션이 빠짐 | `false` |
-| `MULTI_KEY_UNSUPPORTED` | 멀티 키 슬롯이 있는데 `multiKey` 선언 없이 `keys`를 씀 | `false` |
-| `MUTATION_ID_REUSED` | 같은 mutation ID를 다른 요청에 재사용함 | `false` |
-| `IO_ERROR` | 문서를 디스크에 저장하지 못함 | `true` |
-| `HISTORY_IN_PROGRESS` | undo/redo 진행 중. 끝난 뒤 재시도 | `true` |
-| `HISTORY_EPOCH_CONFLICT` | 관측한 history epoch이 오래됨. 다시 조회한 뒤 커밋 | `true` |
+| 코드 | 의미 | `retryable` |
+| -------------------------- | --------------------------------------------------------- | ----------- |
+| `REVISION_CONFLICT` | `baseRevision`이 오래됨. 다시 조회·조정한 뒤 커밋 | `true` |
+| `PLUGIN_REVISION_CONFLICT` | 플러그인 범위 revision이 오래됨. 다시 조회한 뒤 커밋 | `true` |
+| `VALIDATION_FAILED` | 완성된 문서가 에디터 검증 규칙을 위반함 | `false` |
+| `TOO_MANY_GESTURE_IDS` | `gestureIds`가 32개를 넘거나 합친 고유 ID가 32개를 초과함 | `false` |
+| `INVALID_GESTURE_ID` | gesture ID가 UUID가 아니거나 64바이트를 초과함 | `false` |
+| `PAIRED_UPDATE_REQUIRED` | 키 구조 변경에 짝 컬렉션이 빠짐 | `false` |
+| `MULTI_KEY_UNSUPPORTED` | 멀티 키 슬롯이 있는데 `multiKey` 선언 없이 `keys`를 씀 | `false` |
+| `MUTATION_ID_REUSED` | 같은 mutation ID를 다른 요청에 재사용함 | `false` |
+| `IO_ERROR` | 문서를 디스크에 저장하지 못함 | `true` |
+| `HISTORY_IN_PROGRESS` | undo/redo 진행 중. 끝난 뒤 재시도 | `true` |
+| `HISTORY_EPOCH_CONFLICT` | 관측한 history epoch이 오래됨. 다시 조회한 뒤 커밋 | `true` |
오류에 해당할 때 `details.currentRevision`, `details.validationCode`,
`details.field`, `details.currentHistoryEpoch`가 함께 제공됩니다.
diff --git a/docs/content/ko/api-reference/keys/page.mdx b/docs/content/ko/api-reference/keys/page.mdx
index 607703faa..957b5c581 100644
--- a/docs/content/ko/api-reference/keys/page.mdx
+++ b/docs/content/ko/api-reference/keys/page.mdx
@@ -111,13 +111,24 @@ interface KeyPosition {
dy: number;
width: number;
height: number;
+ rotation: number; // 도, -180~180. 생략하면 0. 상자 중심 기준 콘텐츠 회전
activeImage?: string;
inactiveImage?: string;
activeTransparent?: boolean;
idleTransparent?: boolean;
imageMode?: 'replace' | 'overlay'; // 이미지 배치. 없으면 replace(이미지가 키를 대체)
- idleImageTransform?: { offsetX: number; offsetY: number; rotation: number; scale: number }; // 없으면 identity
- activeImageTransform?: { offsetX: number; offsetY: number; rotation: number; scale: number };
+ idleImageTransform?: {
+ offsetX: number;
+ offsetY: number;
+ rotation: number;
+ scale: number;
+ }; // 없으면 identity
+ activeImageTransform?: {
+ offsetX: number;
+ offsetY: number;
+ rotation: number;
+ scale: number;
+ };
count: number;
noteColor: string | { type: 'gradient'; top: string; bottom: string };
noteOpacity: number;
@@ -178,9 +189,11 @@ interface ElementShadowSpec {
```
모든 요소 위치(`keyPositions`, `statPositions`, `graphPositions`,
-`knobPositions`)는 안정 `id`를 가집니다. 불투명 값으로 다루세요. 읽은 값을
-그대로 되돌려 보내고, 직접 만들지 마세요. `id`가 없거나 미확인인 쓰기는
-백엔드가 새 값을 발급합니다. 같은 `id`가 여러 번 나오면(읽은 요소를 복사해
+`knobPositions`, `spritePositions`)는 안정 `id`를 가집니다. 불투명 값으로 다루세요. 읽은 값을
+그대로 되돌려 보내고, 직접 만들지 마세요. `id`가 없거나 미확인인 쓰기는 나머지
+필드가 같은 기존 요소의 신원을 승계합니다. 맞는 항목이 없으면 백엔드가
+새 값을 발급합니다. `id`를 빼는 것이 새 신원을 보장하지는 않고, 값을 바꾸면서
+`id`까지 빼면 새 신원이 될 수 있습니다. 같은 `id`가 여러 번 나오면(읽은 요소를 복사해
덧붙이는 경우처럼) 원래 자리의 요소가 신원을 지키고 나머지 사본이 새 값을
받습니다. 전체 프리셋을 불러오면 모든 `id`가 재발급되고, 탭 프리셋은 그
프리셋이 위치를 준 컬렉션만 재발급됩니다. 재정렬을 가로질러 요소를 추적할
@@ -218,6 +231,16 @@ interface ElementShadowSpec {
생략하면 기존 기본 그림자가 유지되며, 그림자를 명시적으로 끄려면
`enabled: false`인 spec을 저장하세요.
+`rotation`은 요소 회전(도)입니다. 키·통계·그래프·노브가 같은 필드를 쓰며
+`dx`/`dy`/`width`/`height` 논리 상자는 축 정렬 그대로 두고 얼굴, 노트
+트랙, 외부 카운터가 상자 중심을 축으로 함께 돕니다. 생략하면 0이고
+`null`, 비유한값, `-180~180` 밖의 값은 거절됩니다. 자동 히트라인
+보정(`noteAutoYCorrection`)은 회전 0인 키는 콘텐츠 상단에, 회전한 키는
+같은 방향(각도 차 0.5° 이내)으로 도는 키끼리 가장 앞선 상변에 시작선을
+맞춥니다. 혼자 돌린 키는 자기 상변에서 시작하고 노트 오프셋은 그 뒤에
+적용됩니다. 스프라이트도 요소 배치용 `rotation`을 가지며 대기·자세별 회전과
+독립적으로 적용합니다. [스프라이트 위치 타입](/docs/api-reference/editor#스프라이트-위치-타입)을 참고하세요.
+
```javascript
const positions = await dmn.keys.getPositions();
console.log('4key 위치:', positions['4key']);
diff --git a/docs/content/ko/api-reference/overlay/page.mdx b/docs/content/ko/api-reference/overlay/page.mdx
index 784b73f0d..6552be691 100644
--- a/docs/content/ko/api-reference/overlay/page.mdx
+++ b/docs/content/ko/api-reference/overlay/page.mdx
@@ -71,7 +71,10 @@ interface ResizePayload {
width: number;
height: number;
anchor?: string;
+ contentLeftOffset?: number;
contentTopOffset?: number;
+ fixedPositionDeltaX?: number;
+ fixedPositionDeltaY?: number;
}
```
diff --git a/docs/content/ko/api-reference/presets/page.mdx b/docs/content/ko/api-reference/presets/page.mdx
index 97a160aa1..a420ce783 100644
--- a/docs/content/ko/api-reference/presets/page.mdx
+++ b/docs/content/ko/api-reference/presets/page.mdx
@@ -16,9 +16,9 @@ description: 설정 프리셋 저장 및 불러오기 API
```javascript
const result = await dmn.presets.save();
if (result.success) {
- console.log("프리셋 저장 완료");
+ console.log('프리셋 저장 완료');
} else {
- console.error("저장 실패:", result.error);
+ console.error('저장 실패:', result.error);
}
```
@@ -31,9 +31,9 @@ if (result.success) {
```javascript
const result = await dmn.presets.load();
if (result.success) {
- console.log("프리셋 불러오기 완료");
+ console.log('프리셋 불러오기 완료');
} else {
- console.error("불러오기 실패:", result.error);
+ console.error('불러오기 실패:', result.error);
}
```
@@ -52,7 +52,9 @@ const result = await dmn.presets.saveTab();
탭 프리셋 파일을 현재 선택된 탭에 불러옵니다. 파일 선택 다이얼로그가
열립니다. 탭 프리셋에는 `tabOrder`와 `barCount`가 들어 있지 않아 전역 탭 순서를
-건드리지 않습니다.
+건드리지 않습니다. 파일에 담긴 요소 컬렉션(키·통계·그래프·노브·스프라이트)은 탭
+내용을 통째로 대체하므로 파일 쪽 탭에 없는 요소는 사라집니다. 컬렉션 자체가
+없는 옛 프리셋은 그 요소를 그대로 둡니다.
```javascript
const result = await dmn.presets.loadTab();
@@ -65,32 +67,32 @@ const result = await dmn.presets.loadTab();
```javascript
// @id preset-manager
-if (dmn.window.type !== "main") return;
+if (dmn.window.type !== 'main') return;
// 프리셋 저장 메뉴
dmn.ui.contextMenu.addGridMenuItem({
- id: "save-preset",
- label: "프리셋 저장",
+ id: 'save-preset',
+ label: '프리셋 저장',
onClick: async () => {
const result = await dmn.presets.save();
if (result.success) {
- await dmn.ui.dialog.alert("프리셋이 저장되었습니다!");
+ await dmn.ui.dialog.alert('프리셋이 저장되었습니다!');
}
},
});
// 프리셋 불러오기 메뉴
dmn.ui.contextMenu.addGridMenuItem({
- id: "load-preset",
- label: "프리셋 불러오기",
+ id: 'load-preset',
+ label: '프리셋 불러오기',
onClick: async () => {
const confirmed = await dmn.ui.dialog.confirm(
- "현재 설정이 덮어씌워집니다. 계속하시겠습니까?"
+ '현재 설정이 덮어씌워집니다. 계속하시겠습니까?',
);
if (confirmed) {
const result = await dmn.presets.load();
if (result.success) {
- await dmn.ui.dialog.alert("프리셋을 불러왔습니다!");
+ await dmn.ui.dialog.alert('프리셋을 불러왔습니다!');
}
}
},
@@ -117,6 +119,7 @@ interface PresetSnapshot {
statPositions: StatItemPositions;
graphPositions: GraphItemPositions;
knobPositions: KnobItemPositions;
+ spritePositions: SpritePositions;
customTabs: CustomTab[];
tabOrder: string[];
barCount: number;
@@ -127,36 +130,37 @@ interface PresetSnapshot {
```javascript
const unsub = dmn.presets.onSnapshot((snapshot) => {
- console.log("프리셋 로드됨:", snapshot.selectedKeyType);
- console.log("키 매핑:", snapshot.keys);
+ console.log('프리셋 로드됨:', snapshot.selectedKeyType);
+ console.log('키 매핑:', snapshot.keys);
});
```
- 프리셋 로드 시 개별 이벤트(`onChanged`, `onPositionsChanged` 등)는 발행되지 않습니다.
- 프리셋 로드를 감지하려면 `onSnapshot`을 사용하세요.
+ 프리셋 로드 시 개별 이벤트(`onChanged`, `onPositionsChanged` 등)는 발행되지
+ 않습니다. 프리셋 로드를 감지하려면 `onSnapshot`을 사용하세요.
## 프리셋에 포함되는 항목
프리셋은 “전체 설정 덤프”가 아니라, 아래 항목들을 파일로 저장/복원합니다.
-| 필드 | 설명 |
-| --- | --- |
-| `keys` | 키 매핑 |
-| `positions` | 키 위치/크기/개별 옵션(클래스, 이미지, 카운터 설정 포함) |
-| `customTabs` | 커스텀 탭 목록 |
-| `tabOrder` | 내장 모드와 커스텀 탭을 합친 표시 순서 |
-| `barCount` | 그중 툴바 바에 나오는 앞쪽 개수 (1~4) |
-| `selectedKeyType` | 선택된 탭/모드 |
-| `backgroundColor` | 오버레이 배경색 |
-| `noteSettings` / `noteEffect` | 노트 이펙트 관련 설정 |
-| `laboratoryEnabled` | 실험실(랩) 기능 사용 여부 |
-| `useCustomCSS` / `customCSS` | 커스텀 CSS 사용 여부/내용 |
-| `useCustomJS` / `customJS` | 커스텀 JS 사용 여부/내용(플러그인 목록 포함) |
+| 필드 | 설명 |
+| ----------------------------- | -------------------------------------------------------- |
+| `keys` | 키 매핑 |
+| `positions` | 키 위치/크기/개별 옵션(클래스, 이미지, 카운터 설정 포함) |
+| `customTabs` | 커스텀 탭 목록 |
+| `tabOrder` | 내장 모드와 커스텀 탭을 합친 표시 순서 |
+| `barCount` | 그중 툴바 바에 나오는 앞쪽 개수 (1~4) |
+| `selectedKeyType` | 선택된 탭/모드 |
+| `backgroundColor` | 오버레이 배경색 |
+| `noteSettings` / `noteEffect` | 노트 이펙트 관련 설정 |
+| `laboratoryEnabled` | 실험실(랩) 기능 사용 여부 |
+| `useCustomCSS` / `customCSS` | 커스텀 CSS 사용 여부/내용 |
+| `useCustomJS` / `customJS` | 커스텀 JS 사용 여부/내용(플러그인 목록 포함) |
- 플러그인 설정(`dmn.plugin.storage`)은 프리셋에 포함되지 않습니다. 플러그인 데이터를 포함하려면 별도로 관리하세요.
+ 플러그인 설정(`dmn.plugin.storage`)은 프리셋에 포함되지 않습니다. 플러그인
+ 데이터를 포함하려면 별도로 관리하세요.
diff --git a/docs/content/ko/custom-css/variables/page.mdx b/docs/content/ko/custom-css/variables/page.mdx
index c4f7b39df..1ff10c7da 100644
--- a/docs/content/ko/custom-css/variables/page.mdx
+++ b/docs/content/ko/custom-css/variables/page.mdx
@@ -182,6 +182,29 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리
}
```
+## 스프라이트 스타일 변수
+
+반응형 스프라이트는 담당 키를 누르는 동안 자세가 바뀌는 이미지 한 층을 그립니다.
+배치와 회전 기준점은 창 크기 계산에 그대로 들어가므로 인라인으로 남고, 아래
+외관 채널만 교체할 수 있습니다. 스프라이트의 **인라인 스타일 우선**을 끄고 쓰세요.
+
+`--sprite-transform`은 이미지의 로컬 자세 변환만 교체합니다. 요소 배치용
+`rotation`은 앱이 상자 중심을 축으로 적용하며 자세 변환 바깥에서 함께 돕니다.
+
+| 변수 | 설명 | 타입 | 예시 |
+| --------------------- | ------------------------------------------------- | -------------- | -------------------------- |
+| `--sprite-transform` | 해석된 자세의 변환 교체 (onPress는 이미지만 바뀜) | `` | `rotate(15deg) scale(1.2)` |
+| `--sprite-transition` | 자세 전환 트랜지션 교체 (whileHeld에만 적용) | `` | `transform 120ms ease-out` |
+
+```css
+.왼손 [data-sprite-element] > img {
+ --sprite-transition: transform 90ms cubic-bezier(0.2, 0, 0, 1);
+}
+```
+
+이미지 요소는 원본 비율 그대로 상자를 채우므로 `object-fit`이 바꿀 것이 없습니다.
+크기 자체를 CSS로 덮으면 자세 이미지의 기준점 정합은 보장하지 않습니다.
+
## 선택자 레퍼런스
### 키 선택자
@@ -221,6 +244,16 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리
| `.노브클래스 [data-knob-element]` | 특정 클래스의 노브 표면 |
| `[data-knob-indicator]` | 노브 중앙 회전 표시 |
+### 스프라이트 선택자
+
+| 선택자 | 설명 |
+| ----------------------------------------- | ----------------------------------------------------- |
+| `[data-sprite-element]` | 모든 스프라이트 요소 상자 |
+| `[data-sprite-element] > img` | 스프라이트 이미지 층 |
+| `[data-sprite-state="inactive"]` | 트리거 키를 누르지 않은 스프라이트 |
+| `[data-sprite-state="active"]` | 트리거 키를 누른 스프라이트 (onPress는 항상 inactive) |
+| `.스프라이트클래스 [data-sprite-element]` | 특정 클래스의 스프라이트 |
+
## 표준 CSS 속성
**인라인 스타일 우선**이 꺼진 요소에는 다음과 같은 표준 외형 속성을 직접 사용할
diff --git a/docs/content/ko/settings/page.mdx b/docs/content/ko/settings/page.mdx
index 9e20c9d93..945379ae2 100644
--- a/docs/content/ko/settings/page.mdx
+++ b/docs/content/ko/settings/page.mdx
@@ -66,7 +66,11 @@ const current = pluginSettings.get();
console.log("API Key:", current.apiKey);
// 설정값 변경
-await pluginSettings.set({ theme: "light" });
+try {
+ await pluginSettings.set({ theme: "light" });
+} catch (error) {
+ console.error("설정 저장 실패:", error);
+}
// 설정 패널 열기 (기본: 속성 패널)
const confirmed = await pluginSettings.open();
@@ -112,6 +116,11 @@ interface PluginSettingsDefinition {
| `reset()` | 기본값으로 초기화 |
| `subscribe(listener)` | 설정 변경 구독 |
+`set()`과 `reset()`은 저장이 실패하면 Promise를 거부합니다. 실패한 편집은 저장된
+설정으로 복원하며, 복원 중 시작한 새 편집은 덮지 않습니다. 변경 콜백과 구독 알림은
+저장 성공 후에만 전달됩니다. 속성 패널의 저장 실패는 안내 후 `open()`이 `false`로
+끝나며, 모달 방식의 저장 실패는 `open()` 호출자에게 오류로 전달됩니다.
+
## onChange vs subscribe()
두 방식 모두 설정 변경을 감지하지만 용도가 다릅니다:
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index a2951ac9a..9a44980b1 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -1158,6 +1158,7 @@ dependencies = [
"dirs-next",
"fern",
"futures-util",
+ "imagesize",
"infer",
"libc",
"local-ip-address",
@@ -2221,6 +2222,12 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "imagesize"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
+
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -5126,6 +5133,7 @@ dependencies = [
"libc",
"mio 1.1.1",
"pin-project-lite",
+ "signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 920d65503..c5bbea473 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -16,7 +16,7 @@ zip = "0.6"
[dependencies]
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
-serde_json = "1.0"
+serde_json = { version = "1.0", features = ["float_roundtrip"] }
base64 = "0.22"
minisign-verify = "0.2.5"
thiserror = "1.0"
@@ -44,6 +44,7 @@ zip = "0.6"
sha2 = "0.10"
quick-xml = "0.38"
infer = "0.19"
+imagesize = "0.15"
rodio = { git = "https://github.com/lee-sihun/rodio", rev = "7b771e15", default-features = false, features = ["playback"] }
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis", "wav", "aiff", "mp3", "isomp4", "aac", "pcm", "adpcm", "flac", "alac"] }
tokio = { version = "1", features = ["rt", "net", "sync", "time", "macros"] }
@@ -56,6 +57,9 @@ wuff = "0.2.8"
[dev-dependencies]
libc = "0.2"
+[target."cfg(unix)".dependencies]
+tokio = { version = "1", features = ["signal"] }
+
[target."cfg(windows)".dependencies]
cpal = { version = "0.17.3", optional = true, default-features = false, features = ["asio"] }
windows = { version = "0.61.3", features = [
diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs
index 819681c32..e36a7c90a 100644
--- a/src-tauri/src/app.rs
+++ b/src-tauri/src/app.rs
@@ -205,6 +205,20 @@ pub fn run() {
}
configure_main_window(app.handle());
+ #[cfg(unix)]
+ {
+ let handle = app.handle().clone();
+ state::window::unix_termination::install(move || {
+ if let Some(state) = handle.try_state::() {
+ state.request_frontend_shutdown(handle.clone());
+ }
+ })
+ .map_err(|error| {
+ log::error!("failed to install SIGTERM shutdown handler: {error}");
+ error
+ })?;
+ }
+
#[cfg(target_os = "macos")]
{
state::window::macos_termination::install(app.handle())
diff --git a/src-tauri/src/commands/editor/history.rs b/src-tauri/src/commands/editor/history.rs
index 2074b34f0..9a6f2538d 100644
--- a/src-tauri/src/commands/editor/history.rs
+++ b/src-tauri/src/commands/editor/history.rs
@@ -411,6 +411,7 @@ fn preset_history_event_projection(
stat_positions: snapshot.document.stat_positions.clone(),
graph_positions: snapshot.document.graph_positions.clone(),
knob_positions: snapshot.document.knob_positions.clone(),
+ sprite_positions: snapshot.document.sprite_positions.clone(),
custom_tabs: snapshot.custom_tabs.clone(),
tab_order: snapshot.tab_order.clone(),
bar_count: snapshot.bar_count,
diff --git a/src-tauri/src/commands/editor/state.rs b/src-tauri/src/commands/editor/state.rs
index 8342e85d1..1c2b86459 100644
--- a/src-tauri/src/commands/editor/state.rs
+++ b/src-tauri/src/commands/editor/state.rs
@@ -26,6 +26,7 @@ fn legacy_event_name(field: EditorField) -> &'static str {
EditorField::StatPositions => "statPositions:changed",
EditorField::GraphPositions => "graphPositions:changed",
EditorField::KnobPositions => "knobPositions:changed",
+ EditorField::SpritePositions => "spritePositions:changed",
EditorField::LayerGroups => "layerGroups:changed",
}
}
@@ -77,6 +78,9 @@ pub(crate) fn publish_legacy_editor_fields(
EditorField::KnobPositions => {
emit_best_effort(app, event, &change.document.knob_positions);
}
+ EditorField::SpritePositions => {
+ emit_best_effort(app, event, &change.document.sprite_positions);
+ }
EditorField::LayerGroups => {
emit_best_effort(app, event, &change.document.layer_groups);
}
@@ -300,6 +304,7 @@ mod tests {
EditorField::StatPositions,
EditorField::GraphPositions,
EditorField::KnobPositions,
+ EditorField::SpritePositions,
EditorField::LayerGroups,
];
assert_eq!(&fields, projected_legacy_fields(true, &fields));
@@ -311,6 +316,7 @@ mod tests {
"statPositions:changed",
"graphPositions:changed",
"knobPositions:changed",
+ "spritePositions:changed",
"layerGroups:changed",
]
);
@@ -353,4 +359,76 @@ mod tests {
assert_eq!(wire["details"]["validationCode"], "INVALID_REQUEST_PAYLOAD");
assert_eq!(wire["retryable"], false);
}
+
+ #[test]
+ fn nullable_contract_rejects_null_for_non_nullable_sprite_fields() {
+ let sprite = crate::models::ReactiveSpritePosition {
+ poses: vec![crate::models::SpritePose::default()],
+ ..crate::models::ReactiveSpritePosition::default()
+ };
+ let changes = serde_json::json!({
+ "schemaVersion": 1,
+ "spritePositions": { "4key": [sprite] }
+ });
+
+ for pointer in [
+ "/spritePositions/4key/0/activation",
+ "/spritePositions/4key/0/pressDurationMs",
+ ] {
+ let mut invalid_changes = changes.clone();
+ *invalid_changes.pointer_mut(pointer).unwrap() = Value::Null;
+ let error = decode_editor_commit_request(serde_json::json!({
+ "baseRevision": 0,
+ "mutationId": uuid::Uuid::new_v4().to_string(),
+ "changes": invalid_changes
+ }))
+ .unwrap_err();
+ assert_eq!(
+ error
+ .details
+ .and_then(|details| details.validation_code)
+ .as_deref(),
+ Some("INVALID_REQUEST_PAYLOAD")
+ );
+ }
+ }
+
+ #[test]
+ fn omitted_and_explicit_sprite_fields_have_distinct_mutation_fingerprints() {
+ let sprite = crate::models::ReactiveSpritePosition {
+ poses: vec![crate::models::SpritePose::default()],
+ ..crate::models::ReactiveSpritePosition::default()
+ };
+ let explicit_changes = serde_json::json!({
+ "schemaVersion": 1,
+ "spritePositions": { "4key": [sprite] }
+ });
+ let mut omitted_changes = explicit_changes.clone();
+ let sprite = omitted_changes
+ .pointer_mut("/spritePositions/4key/0")
+ .and_then(Value::as_object_mut)
+ .unwrap();
+ sprite.remove("referenceNaturalSize");
+ sprite.remove("activation");
+ sprite["poses"][0]
+ .as_object_mut()
+ .unwrap()
+ .remove("imageOverrideMetrics");
+ let mutation_id = uuid::Uuid::new_v4().to_string();
+ let decode = |changes| {
+ decode_editor_commit_request(serde_json::json!({
+ "baseRevision": 0,
+ "mutationId": &mutation_id,
+ "changes": changes
+ }))
+ .unwrap()
+ };
+ let explicit = decode(explicit_changes);
+ let omitted = decode(omitted_changes);
+
+ assert_ne!(
+ crate::state::editor::request_fingerprint(&explicit).unwrap(),
+ crate::state::editor::request_fingerprint(&omitted).unwrap()
+ );
+ }
}
diff --git a/src-tauri/src/commands/keys/keys.rs b/src-tauri/src/commands/keys/keys.rs
index 954f2b36f..c1d33fbb8 100644
--- a/src-tauri/src/commands/keys/keys.rs
+++ b/src-tauri/src/commands/keys/keys.rs
@@ -126,6 +126,7 @@ fn reset_all_editor_data(
store.stat_positions = stat_positions.clone();
store.graph_positions.clear();
store.knob_positions.clear();
+ store.sprite_positions.clear();
store.layer_groups.clear();
store.key_counters = zeroed_counters(keys);
store.custom_tabs.clear();
@@ -223,6 +224,7 @@ fn apply_reset_mode_data(store: &mut AppStoreData, mode: &str, kind: ModeResetKi
store.graph_positions.insert(mode.to_string(), Vec::new());
store.knob_positions.insert(mode.to_string(), Vec::new());
+ store.sprite_positions.insert(mode.to_string(), Vec::new());
store.layer_groups.remove(mode);
store.tab_css_overrides.remove(mode);
store.tab_note_overrides.remove(mode);
@@ -278,10 +280,24 @@ fn mode_positions_equal_without_ids(
position.position.id.clear();
}
+ let mut current_sprites = current.sprite_positions.get(mode).cloned();
+ let mut candidate_sprites = candidate.sprite_positions.get(mode).cloned();
+ for sprite in current_sprites
+ .iter_mut()
+ .chain(candidate_sprites.iter_mut())
+ .flatten()
+ {
+ sprite.id.clear();
+ for pose in &mut sprite.poses {
+ pose.pose_id.clear();
+ }
+ }
+
current_keys == candidate_keys
&& optional_collections_equal(current_stats.as_deref(), candidate_stats.as_deref())
&& optional_collections_equal(current_graphs.as_deref(), candidate_graphs.as_deref())
&& optional_collections_equal(current_knobs.as_deref(), candidate_knobs.as_deref())
+ && optional_collections_equal(current_sprites.as_deref(), candidate_sprites.as_deref())
}
fn optional_collections_equal(
@@ -386,6 +402,7 @@ fn delete_custom_tab_data(store: &mut AppStoreData, id: &str, plan: &CustomTabDe
store.stat_positions.remove(id);
store.graph_positions.remove(id);
store.knob_positions.remove(id);
+ store.sprite_positions.remove(id);
store.layer_groups.remove(id);
store.tab_css_overrides.remove(id);
store.tab_note_overrides.remove(id);
@@ -672,6 +689,7 @@ fn keys_reset_all_inner(
EditorField::StatPositions,
EditorField::GraphPositions,
EditorField::KnobPositions,
+ EditorField::SpritePositions,
EditorField::LayerGroups,
],
PluginInstancesResetScope::All,
@@ -705,6 +723,7 @@ fn keys_reset_all_inner(
let stat_positions = transaction.change.document.stat_positions;
let graph_positions = transaction.change.document.graph_positions;
let knob_positions = transaction.change.document.knob_positions;
+ let sprite_positions = transaction.change.document.sprite_positions;
let layer_groups = transaction.change.document.layer_groups;
let selected_key_type = transaction.change.selected_key_type;
let custom_tabs: Vec = Vec::new();
@@ -721,6 +740,7 @@ fn keys_reset_all_inner(
emit_best_effort(app, "statPositions:changed", &stat_positions);
emit_best_effort(app, "graphPositions:changed", &graph_positions);
emit_best_effort(app, "knobPositions:changed", &knob_positions);
+ emit_best_effort(app, "spritePositions:changed", &sprite_positions);
emit_best_effort(app, "layerGroups:changed", &layer_groups);
emit_best_effort(
app,
@@ -794,6 +814,7 @@ fn keys_reset_mode_inner(
EditorField::StatPositions,
EditorField::GraphPositions,
EditorField::KnobPositions,
+ EditorField::SpritePositions,
EditorField::LayerGroups,
],
PluginInstancesResetScope::Mode(mode.clone()),
@@ -841,6 +862,11 @@ fn keys_reset_mode_inner(
"knobPositions:changed",
&transaction.change.document.knob_positions,
);
+ emit_best_effort(
+ app,
+ "spritePositions:changed",
+ &transaction.change.document.sprite_positions,
+ );
emit_best_effort(
app,
"layerGroups:changed",
diff --git a/src-tauri/src/commands/keys/keys/custom_tabs.rs b/src-tauri/src/commands/keys/keys/custom_tabs.rs
index be9fc015b..fad48e7f3 100644
--- a/src-tauri/src/commands/keys/keys/custom_tabs.rs
+++ b/src-tauri/src/commands/keys/keys/custom_tabs.rs
@@ -270,6 +270,7 @@ fn custom_tabs_delete_inner(
EditorField::StatPositions,
EditorField::GraphPositions,
EditorField::KnobPositions,
+ EditorField::SpritePositions,
EditorField::LayerGroups,
],
plugin_instances_reset: PluginInstancesResetScope::Mode(id.clone()),
@@ -342,6 +343,11 @@ fn custom_tabs_delete_inner(
"knobPositions:changed",
&transaction.change.document.knob_positions,
);
+ emit_best_effort(
+ app,
+ "spritePositions:changed",
+ &transaction.change.document.sprite_positions,
+ );
emit_best_effort(
app,
"layerGroups:changed",
diff --git a/src-tauri/src/commands/keys/keys/tests.rs b/src-tauri/src/commands/keys/keys/tests.rs
index e1b1795c8..19593a57f 100644
--- a/src-tauri/src/commands/keys/keys/tests.rs
+++ b/src-tauri/src/commands/keys/keys/tests.rs
@@ -776,6 +776,7 @@ fn reset_mode_with_changed_keys_preserves_other_live_mode_counters() {
EditorField::StatPositions,
EditorField::GraphPositions,
EditorField::KnobPositions,
+ EditorField::SpritePositions,
EditorField::LayerGroups,
],
PluginInstancesResetScope::Mode(reset_mode.to_string()),
@@ -843,6 +844,7 @@ fn reset_mode_with_default_keys_applies_counter_only_reset_to_runtime() {
EditorField::StatPositions,
EditorField::GraphPositions,
EditorField::KnobPositions,
+ EditorField::SpritePositions,
EditorField::LayerGroups,
],
PluginInstancesResetScope::Mode(mode.to_string()),
@@ -903,6 +905,7 @@ fn reset_mode_replays_queued_increment_for_unchanged_mapping() {
EditorField::StatPositions,
EditorField::GraphPositions,
EditorField::KnobPositions,
+ EditorField::SpritePositions,
EditorField::LayerGroups,
],
PluginInstancesResetScope::Mode(reset_mode.to_string()),
@@ -974,6 +977,7 @@ fn reset_mode_drops_queued_increment_for_replaced_key() {
EditorField::StatPositions,
EditorField::GraphPositions,
EditorField::KnobPositions,
+ EditorField::SpritePositions,
EditorField::LayerGroups,
],
PluginInstancesResetScope::Mode(mode.to_string()),
@@ -1041,6 +1045,7 @@ fn reset_all_drops_queued_increment_for_removed_custom_mode() {
EditorField::StatPositions,
EditorField::GraphPositions,
EditorField::KnobPositions,
+ EditorField::SpritePositions,
EditorField::LayerGroups,
],
PluginInstancesResetScope::All,
@@ -1103,6 +1108,7 @@ fn reset_all_with_default_keys_applies_counter_only_reset_to_runtime() {
EditorField::StatPositions,
EditorField::GraphPositions,
EditorField::KnobPositions,
+ EditorField::SpritePositions,
EditorField::LayerGroups,
],
PluginInstancesResetScope::All,
@@ -1179,6 +1185,7 @@ fn reset_all_with_changed_keys_zeroes_every_live_counter() {
EditorField::StatPositions,
EditorField::GraphPositions,
EditorField::KnobPositions,
+ EditorField::SpritePositions,
EditorField::LayerGroups,
],
PluginInstancesResetScope::All,
diff --git a/src-tauri/src/commands/layout/overlay.rs b/src-tauri/src/commands/layout/overlay.rs
index 73d706b34..615b68ce2 100644
--- a/src-tauri/src/commands/layout/overlay.rs
+++ b/src-tauri/src/commands/layout/overlay.rs
@@ -17,6 +17,8 @@ pub struct OverlayResizeArgs {
#[serde(default)]
pub anchor: Option,
#[serde(default)]
+ pub content_left_offset: Option,
+ #[serde(default)]
pub content_top_offset: Option,
#[serde(default)]
pub fixed_position_delta_x: Option,
@@ -152,6 +154,7 @@ pub async fn overlay_resize(
payload.width,
payload.height,
payload.anchor,
+ payload.content_left_offset,
payload.content_top_offset,
payload.fixed_position_delta_x,
payload.fixed_position_delta_y,
diff --git a/src-tauri/src/commands/media/image.rs b/src-tauri/src/commands/media/image.rs
index 749c0d8e1..f7ceefa0d 100644
--- a/src-tauri/src/commands/media/image.rs
+++ b/src-tauri/src/commands/media/image.rs
@@ -14,7 +14,7 @@ use tauri::{Manager, WebviewWindow};
use crate::{
commands::dialog::parented_file_dialog,
errors::{CmdResult, CommandError},
- state::assets::image_asset::import_image_file,
+ state::assets::image_asset::{import_image_file, SUPPORTED_IMAGE_EXTENSIONS},
};
#[derive(Serialize)]
@@ -44,15 +44,9 @@ pub async fn image_load(
app: tauri::AppHandle,
window: WebviewWindow,
) -> CmdResult {
- let picked = parented_file_dialog(
- &window,
- "Images",
- &[
- "png", "jpg", "jpeg", "webp", "gif", "bmp", "svg", "ico", "avif",
- ],
- )
- .pick_file()
- .await;
+ let picked = parented_file_dialog(&window, "Images", SUPPORTED_IMAGE_EXTENSIONS)
+ .pick_file()
+ .await;
let Some(file) = picked else {
return Ok(ImageLoadResponse {
diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs
index d408828dd..f0b596701 100644
--- a/src-tauri/src/commands/preset/load.rs
+++ b/src-tauri/src/commands/preset/load.rs
@@ -20,17 +20,19 @@ use crate::{
errors::{CmdResult, CommandError},
models::{
default_bar_count, default_missing_note_gradient_multipliers, normalize_key_mappings,
- scrub_removed_text_outline_fields, AppStoreData, CustomCss, CustomCssPatch, CustomJs,
- CustomJsPatch, EditorCommitOrigin, EditorField, FontSettings, FontType, GradientSpec,
- GraphPositions, KeyMappings, KeyPosition, KeyPositions, KeySlot, KnobPositions,
- LayerGroups, NoteSettings, NoteSettingsPatch, SettingsPatchInput, StatPositions, TabCss,
- TabCssOverrides, TabNoteSettings, POSITION_COLLECTION_FIELDS, SHADOW_BLUR_MAX,
- SHADOW_BLUR_MIN, SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN,
+ rewrite_coupled_sprite_image_reference, scrub_removed_text_outline_fields, AppStoreData,
+ CustomCss, CustomCssPatch, CustomJs, CustomJsPatch, EditorCommitOrigin, EditorField,
+ FontSettings, FontType, GradientSpec, GraphPositions, KeyMappings, KeyPosition,
+ KeyPositions, KeySlot, KnobPositions, LayerGroups, NoteSettings, NoteSettingsPatch,
+ SettingsPatchInput, SpritePositions, StatPositions, TabCss, TabCssOverrides,
+ TabNoteSettings, POSITION_COLLECTION_FIELDS, SHADOW_BLUR_MAX, SHADOW_BLUR_MIN,
+ SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN,
},
services::settings::apply_patch_to_store,
state::{
assets::image_asset::{import_image_bytes, import_image_file},
editor::validate_history_restore_metadata,
+ migration::{fill_missing_sprite_image_metrics, migrate_legacy_sprite_wire},
tab_metadata::{
legacy_tab_order, normalize_bar_count, normalize_tab_order,
reconcile_custom_tab_metadata,
@@ -56,7 +58,7 @@ use imported_assets::{
#[cfg(test)]
use imported_assets::{
merge_tab_preset_fonts, restore_position_image_reference, restore_preset_local_fonts_in_dir,
- restore_preset_local_sounds_in_dir,
+ restore_preset_local_images_in_dir, restore_preset_local_sounds_in_dir,
};
#[cfg(test)]
use validation::invalid_position_style_detail;
@@ -154,6 +156,7 @@ fn preset_load_from_path(
let mut stat_positions = preset.stat_positions.unwrap_or_default();
let mut graph_positions = preset.graph_positions.unwrap_or_default();
let mut knob_positions = preset.knob_positions.unwrap_or_default();
+ let mut sprite_positions = preset.sprite_positions.unwrap_or_default();
migrate_imported_font_weights(
&mut positions,
&mut stat_positions,
@@ -221,6 +224,7 @@ fn preset_load_from_path(
&mut stat_positions,
&mut graph_positions,
&mut knob_positions,
+ &mut sprite_positions,
preset.embedded_local_images.as_deref(),
)?;
restore_preset_local_sounds(
@@ -288,6 +292,7 @@ fn preset_load_from_path(
EditorField::StatPositions,
EditorField::GraphPositions,
EditorField::KnobPositions,
+ EditorField::SpritePositions,
EditorField::LayerGroups,
],
admission,
@@ -303,6 +308,7 @@ fn preset_load_from_path(
store.stat_positions = stat_positions;
store.graph_positions = graph_positions;
store.knob_positions = knob_positions;
+ store.sprite_positions = sprite_positions;
store.custom_tabs = custom_tabs;
store.tab_order = tab_order;
store.bar_count = bar_count;
@@ -360,6 +366,7 @@ fn preset_load_from_path(
let stat_positions = transaction.change.document.stat_positions;
let graph_positions = transaction.change.document.graph_positions;
let knob_positions = transaction.change.document.knob_positions;
+ let sprite_positions = transaction.change.document.sprite_positions;
let layer_groups = transaction.change.document.layer_groups;
if let Err(error) = state.emit_settings_changed(&diff, app) {
@@ -376,6 +383,7 @@ fn preset_load_from_path(
stat_positions,
graph_positions,
knob_positions,
+ sprite_positions,
custom_tabs,
tab_order,
bar_count,
@@ -438,6 +446,7 @@ fn preset_load_tab_from_path(
stat_positions,
graph_positions,
knob_positions,
+ sprite_positions,
selected_key_type,
tab_note_overrides,
layer_groups,
@@ -471,23 +480,14 @@ fn preset_load_tab_from_path(
src_key_positions.insert(current_tab_id.clone(), v.clone());
}
- let imported_stat_positions = stat_positions.unwrap_or_default();
- let mut src_stat_positions: StatPositions = HashMap::new();
- if let Some(v) = imported_stat_positions.get(&source_tab_id) {
- src_stat_positions.insert(current_tab_id.clone(), v.clone());
- }
-
- let imported_graph_positions = graph_positions.unwrap_or_default();
- let mut src_graph_positions: GraphPositions = HashMap::new();
- if let Some(v) = imported_graph_positions.get(&source_tab_id) {
- src_graph_positions.insert(current_tab_id.clone(), v.clone());
- }
-
- let imported_knob_positions = knob_positions.unwrap_or_default();
- let mut src_knob_positions: KnobPositions = HashMap::new();
- if let Some(v) = imported_knob_positions.get(&source_tab_id) {
- src_knob_positions.insert(current_tab_id.clone(), v.clone());
- }
+ let mut src_stat_positions =
+ select_tab_preset_elements(stat_positions.as_ref(), &source_tab_id, ¤t_tab_id);
+ let mut src_graph_positions =
+ select_tab_preset_elements(graph_positions.as_ref(), &source_tab_id, ¤t_tab_id);
+ let mut src_knob_positions =
+ select_tab_preset_elements(knob_positions.as_ref(), &source_tab_id, ¤t_tab_id);
+ let mut src_sprite_positions =
+ select_tab_preset_elements(sprite_positions.as_ref(), &source_tab_id, ¤t_tab_id);
migrate_imported_font_weights(
&mut src_key_positions,
@@ -509,6 +509,7 @@ fn preset_load_tab_from_path(
&mut src_stat_positions,
&mut src_graph_positions,
&mut src_knob_positions,
+ &mut src_sprite_positions,
embedded_local_images.as_deref(),
)?;
restore_preset_local_sounds(
@@ -524,6 +525,7 @@ fn preset_load_tab_from_path(
let imported_stat_positions = src_stat_positions.remove(¤t_tab_id);
let imported_graph_positions = src_graph_positions.remove(¤t_tab_id);
let imported_knob_positions = src_knob_positions.remove(¤t_tab_id);
+ let imported_sprite_positions = src_sprite_positions.remove(¤t_tab_id);
let imported_override = imported_tab_note_overrides.get(&source_tab_id).cloned();
let imported_groups =
layer_groups.map(|groups| groups.get(&source_tab_id).cloned().unwrap_or_default());
@@ -566,6 +568,7 @@ fn preset_load_tab_from_path(
EditorField::StatPositions,
EditorField::GraphPositions,
EditorField::KnobPositions,
+ EditorField::SpritePositions,
EditorField::LayerGroups,
],
admission,
@@ -575,6 +578,7 @@ fn preset_load_tab_from_path(
let stat_positions_written = imported_stat_positions.is_some();
let graph_positions_written = imported_graph_positions.is_some();
let knob_positions_written = imported_knob_positions.is_some();
+ let sprite_positions_written = imported_sprite_positions.is_some();
merge_tab_preset_key_pair(store, ¤t_tab_id, src_keys, imported_key_positions);
if let Some(positions) = imported_stat_positions {
store
@@ -591,6 +595,11 @@ fn preset_load_tab_from_path(
.knob_positions
.insert(current_tab_id.clone(), positions);
}
+ if let Some(positions) = imported_sprite_positions {
+ store
+ .sprite_positions
+ .insert(current_tab_id.clone(), positions);
+ }
rekey_tab_preset_elements(
store,
¤t_tab_id,
@@ -598,6 +607,7 @@ fn preset_load_tab_from_path(
stat_positions_written,
graph_positions_written,
knob_positions_written,
+ sprite_positions_written,
);
apply_tab_note_override(
store,
@@ -662,6 +672,7 @@ fn preset_load_tab_from_path(
let full_stat_positions = transaction.change.document.stat_positions;
let full_graph_positions = transaction.change.document.graph_positions;
let full_knob_positions = transaction.change.document.knob_positions;
+ let full_sprite_positions = transaction.change.document.sprite_positions;
let full_layer_groups = transaction.change.document.layer_groups;
if let Some(diff) = settings_diff.as_ref() {
@@ -676,6 +687,7 @@ fn preset_load_tab_from_path(
emit_best_effort(app, "statPositions:changed", &full_stat_positions);
emit_best_effort(app, "graphPositions:changed", &full_graph_positions);
emit_best_effort(app, "knobPositions:changed", &full_knob_positions);
+ emit_best_effort(app, "spritePositions:changed", &full_sprite_positions);
emit_best_effort(app, "tabNote:changed_all", &full_tab_note_overrides);
// OBS 브릿지: 탭 프리셋 로드 시 전체 스냅샷 재전송
@@ -806,6 +818,24 @@ fn normalize_imported_tab_css_overrides(
overrides
}
+/// 탭 프리셋의 요소 컬렉션 하나를 대상 탭 키로 옮긴다. 컬렉션이 있는데 원본 탭 항목이
+/// 없으면 "요소 없음"이라 빈 목록을 넣어 기존 요소를 지운다 - 없으면 남는다.
+/// 컬렉션 자체가 없는 옛 프리셋만 기존 요소를 그대로 둔다
+fn select_tab_preset_elements(
+ collection: Option<&HashMap>>,
+ source_tab_id: &str,
+ current_tab_id: &str,
+) -> HashMap> {
+ let mut selected = HashMap::new();
+ if let Some(collection) = collection {
+ selected.insert(
+ current_tab_id.to_string(),
+ collection.get(source_tab_id).cloned().unwrap_or_default(),
+ );
+ }
+ selected
+}
+
fn choose_tab_preset_source_tab(
keys: &KeyMappings,
selected_key_type: Option<&str>,
@@ -866,6 +896,7 @@ fn rekey_tab_preset_elements(
stat_positions_written: bool,
graph_positions_written: bool,
knob_positions_written: bool,
+ sprite_positions_written: bool,
) {
crate::state::native_element_id::rekey_mode_element_ids_for_collections(
store,
@@ -874,6 +905,7 @@ fn rekey_tab_preset_elements(
stat_positions_written,
graph_positions_written,
knob_positions_written,
+ sprite_positions_written,
);
// 프리셋이 위치를 주지 않은 컬렉션은 기존 요소가 값 그대로 남는다 -
// 신원을 회전시키지 않고 정렬이 덧붙인 빈 항목만 채운다
@@ -884,6 +916,7 @@ fn rekey_tab_preset_elements(
!stat_positions_written,
!graph_positions_written,
!knob_positions_written,
+ !sprite_positions_written,
);
}
diff --git a/src-tauri/src/commands/preset/load/imported_assets.rs b/src-tauri/src/commands/preset/load/imported_assets.rs
index 9117eb19e..1722eda36 100644
--- a/src-tauri/src/commands/preset/load/imported_assets.rs
+++ b/src-tauri/src/commands/preset/load/imported_assets.rs
@@ -222,6 +222,28 @@ pub(super) fn restore_preset_local_images(
stat_positions: &mut StatPositions,
graph_positions: &mut GraphPositions,
knob_positions: &mut KnobPositions,
+ sprite_positions: &mut SpritePositions,
+ embedded_local_images: Option<&[EmbeddedLocalImage]>,
+) -> CmdResult<()> {
+ let app_data_dir = app.path().app_data_dir()?;
+ restore_preset_local_images_in_dir(
+ &app_data_dir.join("images"),
+ key_positions,
+ stat_positions,
+ graph_positions,
+ knob_positions,
+ sprite_positions,
+ embedded_local_images,
+ )
+}
+
+pub(super) fn restore_preset_local_images_in_dir(
+ images_dir: &Path,
+ key_positions: &mut KeyPositions,
+ stat_positions: &mut StatPositions,
+ graph_positions: &mut GraphPositions,
+ knob_positions: &mut KnobPositions,
+ sprite_positions: &mut SpritePositions,
embedded_local_images: Option<&[EmbeddedLocalImage]>,
) -> CmdResult<()> {
let has_any_images = key_positions.values().any(|positions| {
@@ -244,6 +266,14 @@ pub(super) fn restore_preset_local_images(
option_has_non_empty_text(&knob_position.position.active_image)
|| option_has_non_empty_text(&knob_position.position.inactive_image)
})
+ }) || sprite_positions.values().any(|sprites| {
+ sprites.iter().any(|sprite| {
+ option_has_non_empty_text(&sprite.base_image)
+ || sprite
+ .poses
+ .iter()
+ .any(|pose| option_has_non_empty_text(&pose.image_override))
+ })
});
if !has_any_images {
@@ -257,20 +287,18 @@ pub(super) fn restore_preset_local_images(
.collect();
let mut restored_path_cache: HashMap = HashMap::new();
- let app_data_dir = app.path().app_data_dir()?;
- let images_dir = app_data_dir.join("images");
- fs::create_dir_all(&images_dir)?;
+ fs::create_dir_all(images_dir)?;
for positions in key_positions.values_mut() {
for position in positions.iter_mut() {
restore_position_image_reference(
- &images_dir,
+ images_dir,
&embedded_map,
&mut restored_path_cache,
&mut position.active_image,
)?;
restore_position_image_reference(
- &images_dir,
+ images_dir,
&embedded_map,
&mut restored_path_cache,
&mut position.inactive_image,
@@ -281,13 +309,13 @@ pub(super) fn restore_preset_local_images(
for positions in stat_positions.values_mut() {
for stat_position in positions.iter_mut() {
restore_position_image_reference(
- &images_dir,
+ images_dir,
&embedded_map,
&mut restored_path_cache,
&mut stat_position.position.active_image,
)?;
restore_position_image_reference(
- &images_dir,
+ images_dir,
&embedded_map,
&mut restored_path_cache,
&mut stat_position.position.inactive_image,
@@ -298,13 +326,13 @@ pub(super) fn restore_preset_local_images(
for positions in graph_positions.values_mut() {
for graph_position in positions.iter_mut() {
restore_position_image_reference(
- &images_dir,
+ images_dir,
&embedded_map,
&mut restored_path_cache,
&mut graph_position.position.active_image,
)?;
restore_position_image_reference(
- &images_dir,
+ images_dir,
&embedded_map,
&mut restored_path_cache,
&mut graph_position.position.inactive_image,
@@ -315,13 +343,13 @@ pub(super) fn restore_preset_local_images(
for positions in knob_positions.values_mut() {
for knob_position in positions.iter_mut() {
restore_position_image_reference(
- &images_dir,
+ images_dir,
&embedded_map,
&mut restored_path_cache,
&mut knob_position.position.active_image,
)?;
restore_position_image_reference(
- &images_dir,
+ images_dir,
&embedded_map,
&mut restored_path_cache,
&mut knob_position.position.inactive_image,
@@ -329,6 +357,31 @@ pub(super) fn restore_preset_local_images(
}
}
+ for sprites in sprite_positions.values_mut() {
+ for sprite in sprites {
+ rewrite_coupled_sprite_image_reference(sprite, |image_ref| {
+ restore_position_image_reference(
+ images_dir,
+ &embedded_map,
+ &mut restored_path_cache,
+ image_ref,
+ )
+ })?;
+ for pose in &mut sprite.poses {
+ rewrite_coupled_sprite_image_reference(pose, |image_ref| {
+ restore_position_image_reference(
+ images_dir,
+ &embedded_map,
+ &mut restored_path_cache,
+ image_ref,
+ )
+ })?;
+ }
+ }
+ }
+
+ fill_missing_sprite_image_metrics(sprite_positions);
+
Ok(())
}
diff --git a/src-tauri/src/commands/preset/load/tests.rs b/src-tauri/src/commands/preset/load/tests.rs
index baf117d49..d3a4a6b2b 100644
--- a/src-tauri/src/commands/preset/load/tests.rs
+++ b/src-tauri/src/commands/preset/load/tests.rs
@@ -3,10 +3,150 @@ use crate::{
defaults::{default_keys, default_positions},
models::{
CustomCssHistoryEntry, CustomFont, FontWeightRange, GraphPosition, GraphStatType,
- GraphType, JsPlugin, KnobPosition, StatPosition, StatType,
+ GraphType, JsPlugin, KnobPosition, ReactiveSpritePosition, SpriteAnchor,
+ SpriteImageMetrics, SpritePose, SpriteReferenceNaturalSize, StatPosition, StatType,
},
};
+fn png_header(width: u32, height: u32) -> Vec {
+ let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
+ bytes.extend_from_slice(&width.to_be_bytes());
+ bytes.extend_from_slice(&height.to_be_bytes());
+ bytes
+}
+
+#[test]
+fn preset_image_restore_fills_missing_sprite_metrics() {
+ let root = std::env::temp_dir().join(format!(
+ "dmnote-preset-sprite-metrics-{}",
+ uuid::Uuid::new_v4()
+ ));
+ let mut sprites = SpritePositions::from([(
+ "4key".to_string(),
+ vec![ReactiveSpritePosition {
+ base_image: Some(format!("{PRESET_LOCAL_IMAGE_PREFIX}base")),
+ poses: vec![SpritePose {
+ image_override: Some(format!("{PRESET_LOCAL_IMAGE_PREFIX}pose")),
+ ..SpritePose::default()
+ }],
+ ..ReactiveSpritePosition::default()
+ }],
+ )]);
+ let embedded = [
+ EmbeddedLocalImage {
+ image_id: "base".to_string(),
+ extension: Some("png".to_string()),
+ data_base64: BASE64_STANDARD.encode(png_header(640, 360)),
+ },
+ EmbeddedLocalImage {
+ image_id: "pose".to_string(),
+ extension: Some("png".to_string()),
+ data_base64: BASE64_STANDARD.encode(png_header(320, 240)),
+ },
+ ];
+
+ restore_preset_local_images_in_dir(
+ &root.join("images"),
+ &mut KeyPositions::new(),
+ &mut StatPositions::new(),
+ &mut GraphPositions::new(),
+ &mut KnobPositions::new(),
+ &mut sprites,
+ Some(&embedded),
+ )
+ .unwrap();
+
+ let sprite = &sprites["4key"][0];
+ assert_eq!(
+ sprite
+ .reference_natural_size
+ .as_ref()
+ .map(|size| (size.width, size.height)),
+ Some((640, 360))
+ );
+ assert_eq!(
+ sprite.poses[0]
+ .image_override_metrics
+ .as_ref()
+ .map(|size| (size.width, size.height)),
+ Some((320, 240))
+ );
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+#[test]
+fn preset_reader_migrates_legacy_sprite_wire_before_deserialization() {
+ let root = std::env::temp_dir().join(format!(
+ "dmnote-preset-sprite-wire-{}",
+ uuid::Uuid::new_v4()
+ ));
+ let path = root.join("preset.json");
+ std::fs::create_dir_all(&root).unwrap();
+ let mut value = serde_json::to_value(PresetFile {
+ sprite_positions: Some(SpritePositions::from([(
+ "4key".to_string(),
+ vec![ReactiveSpritePosition {
+ dx: 10.0,
+ dy: 20.0,
+ poses: vec![SpritePose::default()],
+ ..ReactiveSpritePosition::default()
+ }],
+ )])),
+ ..PresetFile::default()
+ })
+ .unwrap();
+ let sprite = value["spritePositions"]["4key"][0].as_object_mut().unwrap();
+ sprite.remove("rotation");
+ sprite.insert(
+ "imageRect".to_string(),
+ serde_json::json!({ "x": 5.0, "y": -2.0, "width": 320.0, "height": 180.0 }),
+ );
+ sprite.insert("imagePlacement".to_string(), serde_json::json!("pivot"));
+ sprite["poses"][0]["contactPoint"] = serde_json::json!({ "x": 0.5, "y": 1.0 });
+ std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
+
+ let preset = read_preset_file(&path).unwrap();
+ let sprite = &preset.sprite_positions.unwrap()["4key"][0];
+ assert_eq!(sprite.rotation, 0.0);
+ assert_eq!((sprite.dx, sprite.dy), (15.0, 18.0));
+ assert_eq!((sprite.width, sprite.height), (320.0, 180.0));
+ let wire = serde_json::to_value(sprite).unwrap();
+ for field in ["imageRect", "imagePlacement"] {
+ assert!(wire.get(field).is_none());
+ }
+ assert!(wire["poses"][0].get("contactPoint").is_none());
+
+ std::fs::remove_dir_all(root).unwrap();
+}
+
+fn pivot_sprite_with_local_images(base_image: &Path, pose_image: &Path) -> ReactiveSpritePosition {
+ let base_image = base_image.to_string_lossy().into_owned();
+ let pose_image = pose_image.to_string_lossy().into_owned();
+ ReactiveSpritePosition {
+ id: uuid::Uuid::new_v4().to_string(),
+ base_image: Some(base_image.clone()),
+ reference_natural_size: Some(SpriteReferenceNaturalSize {
+ source: Some(base_image),
+ width: 800,
+ height: 600,
+ }),
+ poses: vec![SpritePose {
+ pose_id: uuid::Uuid::new_v4().to_string(),
+ triggers: vec![uuid::Uuid::new_v4().to_string()],
+ pivot: Some(SpriteAnchor { x: 0.25, y: 0.75 }),
+ image_override: Some(pose_image.clone()),
+ image_override_metrics: Some(SpriteImageMetrics {
+ source: pose_image,
+ width: 320,
+ height: 240,
+ }),
+ ..SpritePose::default()
+ }],
+ ..ReactiveSpritePosition::default()
+ }
+}
+
#[test]
fn full_preset_settings_patch_preserves_custom_css_history() {
let history = vec![CustomCssHistoryEntry {
@@ -61,6 +201,282 @@ fn synthesized_custom_tabs_are_deterministic_without_preset_metadata() {
);
}
+#[test]
+fn reactive_sprite_and_images_survive_preset_export_import_round_trip() {
+ let root = std::env::temp_dir().join(format!(
+ "dmnote-reactive-sprite-preset-round-trip-{}",
+ uuid::Uuid::new_v4()
+ ));
+ let source_dir = root.join("source");
+ let restored_dir = root.join("restored");
+ std::fs::create_dir_all(&source_dir).unwrap();
+ let base_image = source_dir.join("base.png");
+ let pose_image = source_dir.join("pose.png");
+ std::fs::write(&base_image, b"base-image").unwrap();
+ std::fs::write(&pose_image, b"pose-image").unwrap();
+
+ let sprite = pivot_sprite_with_local_images(&base_image, &pose_image);
+ let sprite_id = sprite.id.clone();
+ let pose_id = sprite.poses[0].pose_id.clone();
+ let trigger_id = sprite.poses[0].triggers[0].clone();
+ let sprites = SpritePositions::from([("4key".to_string(), vec![sprite])]);
+
+ let (_, _, _, _, exported_sprites, embedded) =
+ crate::commands::preset::save::build_preset_image_payload(
+ &KeyPositions::new(),
+ &StatPositions::new(),
+ &GraphPositions::new(),
+ &KnobPositions::new(),
+ &sprites,
+ )
+ .unwrap();
+ assert_eq!(embedded.len(), 2);
+ let exported = &exported_sprites["4key"][0];
+ assert_eq!(
+ exported.reference_natural_size.as_ref().unwrap().source,
+ exported.base_image
+ );
+ assert_eq!(
+ exported.poses[0]
+ .image_override_metrics
+ .as_ref()
+ .unwrap()
+ .source,
+ exported.poses[0].image_override.as_deref().unwrap()
+ );
+
+ let preset_bytes = serde_json::to_vec(&PresetFile {
+ sprite_positions: Some(exported_sprites),
+ embedded_local_images: Some(embedded),
+ ..PresetFile::default()
+ })
+ .unwrap();
+ let preset_json = std::str::from_utf8(&preset_bytes).unwrap();
+ assert!(!preset_json.contains(base_image.to_string_lossy().as_ref()));
+ assert!(!preset_json.contains(pose_image.to_string_lossy().as_ref()));
+ let imported: PresetFile = serde_json::from_slice(&preset_bytes).unwrap();
+ let mut imported_sprites = imported.sprite_positions.unwrap();
+
+ restore_preset_local_images_in_dir(
+ &restored_dir,
+ &mut KeyPositions::new(),
+ &mut StatPositions::new(),
+ &mut GraphPositions::new(),
+ &mut KnobPositions::new(),
+ &mut imported_sprites,
+ imported.embedded_local_images.as_deref(),
+ )
+ .unwrap();
+
+ let restored = &imported_sprites["4key"][0];
+ assert_eq!(restored.id, sprite_id);
+ assert_eq!(restored.poses[0].pose_id, pose_id);
+ assert_eq!(restored.poses[0].triggers, [trigger_id]);
+ assert_eq!(
+ restored.poses[0].pivot,
+ Some(SpriteAnchor { x: 0.25, y: 0.75 })
+ );
+ let restored_base = Path::new(restored.base_image.as_deref().unwrap());
+ let restored_pose = Path::new(restored.poses[0].image_override.as_deref().unwrap());
+ assert_eq!(
+ restored.reference_natural_size.as_ref().unwrap().source,
+ restored.base_image
+ );
+ assert_eq!(
+ restored.poses[0]
+ .image_override_metrics
+ .as_ref()
+ .unwrap()
+ .source,
+ restored.poses[0].image_override.as_deref().unwrap()
+ );
+ assert_eq!(std::fs::read(restored_base).unwrap(), b"base-image");
+ assert_eq!(std::fs::read(restored_pose).unwrap(), b"pose-image");
+
+ let _ = std::fs::remove_dir_all(root);
+}
+
+#[test]
+fn pivot_sprite_sources_survive_tab_preset_image_round_trip() {
+ let root = std::env::temp_dir().join(format!(
+ "dmnote-pivot-sprite-tab-preset-round-trip-{}",
+ uuid::Uuid::new_v4()
+ ));
+ let source_dir = root.join("source");
+ let restored_dir = root.join("restored");
+ std::fs::create_dir_all(&source_dir).unwrap();
+ let base_image = source_dir.join("base.png");
+ let pose_image = source_dir.join("pose.png");
+ std::fs::write(&base_image, b"tab-base-image").unwrap();
+ std::fs::write(&pose_image, b"tab-pose-image").unwrap();
+
+ let sprites = SpritePositions::from([(
+ "source-tab".to_string(),
+ vec![pivot_sprite_with_local_images(&base_image, &pose_image)],
+ )]);
+ let (_, _, _, _, exported_sprites, embedded) =
+ crate::commands::preset::save::build_preset_image_payload(
+ &KeyPositions::new(),
+ &StatPositions::new(),
+ &GraphPositions::new(),
+ &KnobPositions::new(),
+ &sprites,
+ )
+ .unwrap();
+ let preset_bytes = serde_json::to_vec(&PresetFile {
+ sprite_positions: Some(exported_sprites),
+ selected_key_type: Some("source-tab".to_string()),
+ embedded_local_images: Some(embedded),
+ ..PresetFile::default()
+ })
+ .unwrap();
+ let preset_json = std::str::from_utf8(&preset_bytes).unwrap();
+ assert!(!preset_json.contains(base_image.to_string_lossy().as_ref()));
+ assert!(!preset_json.contains(pose_image.to_string_lossy().as_ref()));
+
+ let imported: PresetFile = serde_json::from_slice(&preset_bytes).unwrap();
+ let mut imported_sprites = SpritePositions::from([(
+ "target-tab".to_string(),
+ imported.sprite_positions.unwrap()["source-tab"].clone(),
+ )]);
+ restore_preset_local_images_in_dir(
+ &restored_dir,
+ &mut KeyPositions::new(),
+ &mut StatPositions::new(),
+ &mut GraphPositions::new(),
+ &mut KnobPositions::new(),
+ &mut imported_sprites,
+ imported.embedded_local_images.as_deref(),
+ )
+ .unwrap();
+
+ let restored = &imported_sprites["target-tab"][0];
+ assert_eq!(
+ restored.poses[0].pivot,
+ Some(SpriteAnchor { x: 0.25, y: 0.75 })
+ );
+ assert_eq!(
+ restored.reference_natural_size.as_ref().unwrap().source,
+ restored.base_image
+ );
+ assert_eq!(
+ restored.poses[0]
+ .image_override_metrics
+ .as_ref()
+ .unwrap()
+ .source,
+ restored.poses[0].image_override.as_deref().unwrap()
+ );
+ assert_eq!(
+ std::fs::read(restored.base_image.as_ref().unwrap()).unwrap(),
+ b"tab-base-image"
+ );
+ assert_eq!(
+ std::fs::read(restored.poses[0].image_override.as_ref().unwrap()).unwrap(),
+ b"tab-pose-image"
+ );
+
+ let _ = std::fs::remove_dir_all(root);
+}
+
+#[test]
+fn preset_import_round_trips_sprite_activation_and_ignores_removed_match_mode() {
+ let root = std::env::temp_dir().join(format!(
+ "dmnote-stale-sprite-strategy-preset-{}",
+ uuid::Uuid::new_v4()
+ ));
+ std::fs::create_dir_all(&root).unwrap();
+ let path = root.join("preset.json");
+ let sprites = SpritePositions::from([(
+ "4key".to_string(),
+ vec![ReactiveSpritePosition {
+ id: uuid::Uuid::new_v4().to_string(),
+ poses: vec![SpritePose {
+ pose_id: uuid::Uuid::new_v4().to_string(),
+ triggers: vec![uuid::Uuid::new_v4().to_string()],
+ ..SpritePose::default()
+ }],
+ ..ReactiveSpritePosition::default()
+ }],
+ )]);
+ let mut value = serde_json::to_value(PresetFile {
+ sprite_positions: Some(sprites),
+ ..PresetFile::default()
+ })
+ .unwrap();
+ let sprite = value
+ .pointer_mut("/spritePositions/4key/0")
+ .and_then(serde_json::Value::as_object_mut)
+ .unwrap();
+ sprite.insert("activation".to_string(), serde_json::json!("onPress"));
+ sprite
+ .get_mut("poses")
+ .and_then(serde_json::Value::as_array_mut)
+ .and_then(|poses| poses.first_mut())
+ .and_then(serde_json::Value::as_object_mut)
+ .unwrap()
+ .insert("matchMode".to_string(), serde_json::json!("exact"));
+ std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
+
+ let imported = read_preset_file(&path).unwrap();
+ let serialized = serde_json::to_value(imported).unwrap();
+ let sprite = serialized
+ .pointer("/spritePositions/4key/0")
+ .and_then(serde_json::Value::as_object)
+ .unwrap();
+
+ assert_eq!(sprite["activation"], serde_json::json!("onPress"));
+ assert!(!sprite["poses"][0]
+ .as_object()
+ .unwrap()
+ .contains_key("matchMode"));
+ let _ = std::fs::remove_dir_all(root);
+}
+
+#[test]
+fn preset_import_defaults_missing_sprite_oneshot_fields() {
+ let root = std::env::temp_dir().join(format!(
+ "dmnote-missing-sprite-oneshot-fields-preset-{}",
+ uuid::Uuid::new_v4()
+ ));
+ std::fs::create_dir_all(&root).unwrap();
+ let path = root.join("preset.json");
+ let sprites = SpritePositions::from([(
+ "4key".to_string(),
+ vec![ReactiveSpritePosition {
+ id: uuid::Uuid::new_v4().to_string(),
+ poses: vec![SpritePose {
+ pose_id: uuid::Uuid::new_v4().to_string(),
+ triggers: vec![uuid::Uuid::new_v4().to_string()],
+ ..SpritePose::default()
+ }],
+ ..ReactiveSpritePosition::default()
+ }],
+ )]);
+ let mut value = serde_json::to_value(PresetFile {
+ sprite_positions: Some(sprites),
+ ..PresetFile::default()
+ })
+ .unwrap();
+ let sprite = value
+ .pointer_mut("/spritePositions/4key/0")
+ .and_then(serde_json::Value::as_object_mut)
+ .unwrap();
+ sprite.remove("activation");
+ sprite.remove("pressDurationMs");
+ std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
+
+ let imported = read_preset_file(&path).unwrap();
+ let sprite = &imported.sprite_positions.as_ref().unwrap()["4key"][0];
+
+ assert_eq!(
+ serde_json::to_value(sprite.activation).unwrap(),
+ serde_json::json!("whileHeld")
+ );
+ assert_eq!(sprite.press_duration_ms, 300);
+ let _ = std::fs::remove_dir_all(root);
+}
+
#[test]
fn committed_preset_css_paths_exclude_unrelated_store_paths() {
let mut committed = AppStoreData {
@@ -348,6 +764,39 @@ fn damaged_gradient_preset_reports_element_and_field_with_existing_error_code()
let _ = std::fs::remove_dir_all(temp_dir);
}
+#[test]
+fn preset_rejects_invalid_element_rotations_with_path_for_all_position_types() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("preset.json");
+ for collection in POSITION_COLLECTION_FIELDS {
+ for invalid in [
+ serde_json::json!(-180.1),
+ serde_json::json!(180.1),
+ serde_json::Value::Null,
+ serde_json::json!("45"),
+ serde_json::json!(true),
+ ] {
+ let mut preset = serde_json::json!({});
+ preset[collection] = serde_json::json!({
+ "custom mode": [{}, { "rotation": invalid }]
+ });
+ std::fs::write(&path, serde_json::to_vec(&preset).unwrap()).unwrap();
+ let error = read_preset_file(&path)
+ .err()
+ .expect("invalid rotation must be rejected");
+ assert_eq!(error.to_string(), format!(
+ "invalid-preset: {collection}[\"custom mode\"][1].rotation: must be a finite number between -180 and 180"
+ ));
+ }
+ for rotation in [-180.0, -45.5, 0.0, 180.0] {
+ let mut preset = serde_json::json!({});
+ preset[collection] =
+ serde_json::json!({ "custom mode": [{}, { "rotation": rotation }] });
+ assert_eq!(invalid_position_style_detail(&preset), None);
+ }
+ }
+}
+
#[test]
fn preset_rejects_out_of_range_image_transform_with_path() {
let preset = serde_json::json!({
@@ -1117,6 +1566,27 @@ fn tab_preset_keeps_all_local_faces_for_a_new_family() {
.is_none());
}
+#[test]
+fn tab_preset_collection_without_source_tab_entry_clears_target_tab() {
+ let sprites = SpritePositions::from([(
+ "other-tab".to_string(),
+ vec![ReactiveSpritePosition::default()],
+ )]);
+
+ // 컬렉션은 있는데 원본 탭 항목이 없다 - 빈 탭이라 대상 탭도 비운다
+ let selected = select_tab_preset_elements(Some(&sprites), "4key", "target");
+ assert_eq!(selected.get("target").map(Vec::len), Some(0));
+
+ // 원본 탭 항목이 있으면 대상 탭 키로 옮긴다
+ let selected = select_tab_preset_elements(Some(&sprites), "other-tab", "target");
+ assert_eq!(selected.get("target").map(Vec::len), Some(1));
+ assert!(!selected.contains_key("other-tab"));
+
+ // 컬렉션 자체가 없는 옛 프리셋은 대상 탭을 건드리지 않는다
+ let selected = select_tab_preset_elements::(None, "4key", "target");
+ assert!(selected.is_empty());
+}
+
#[test]
fn tab_preset_key_pair_merge_preserves_other_modes_and_latest_target_positions() {
let mut store = AppStoreData {
@@ -1205,9 +1675,9 @@ fn old_tab_preset_rekeys_only_written_collections_on_every_application() {
let untouched_id = store.key_positions["untouched"][0].id.clone();
let original_ids = target_preset_ids(&store);
- rekey_tab_preset_elements(&mut store, "target", true, true, true, false);
+ rekey_tab_preset_elements(&mut store, "target", true, true, true, false, false);
let first_ids = target_preset_ids(&store);
- rekey_tab_preset_elements(&mut store, "target", true, true, true, false);
+ rekey_tab_preset_elements(&mut store, "target", true, true, true, false, false);
let second_ids = target_preset_ids(&store);
assert!(original_ids[..3]
@@ -1240,7 +1710,7 @@ fn tab_preset_without_key_positions_keeps_existing_key_ids() {
.unwrap()
.push(KeyPosition::default());
- rekey_tab_preset_elements(&mut store, "target", false, false, false, false);
+ rekey_tab_preset_elements(&mut store, "target", false, false, false, false, false);
// 값이 그대로인 기존 키는 신원을 지키고, 덧붙은 슬롯만 새 id를 받는다
assert_eq!(store.key_positions["target"][0].id, original_ids[0]);
diff --git a/src-tauri/src/commands/preset/load/validation.rs b/src-tauri/src/commands/preset/load/validation.rs
index 783fbbd4b..6fd5eeba5 100644
--- a/src-tauri/src/commands/preset/load/validation.rs
+++ b/src-tauri/src/commands/preset/load/validation.rs
@@ -1,9 +1,11 @@
use super::*;
+use crate::models::{ELEMENT_ROTATION_MAX, ELEMENT_ROTATION_MIN};
pub(super) fn read_preset_file(path: &Path) -> CmdResult {
let content = fs::read_to_string(path)?;
let mut value: serde_json::Value =
serde_json::from_str(&content).map_err(|_| CommandError::msg("invalid-preset"))?;
+ migrate_legacy_sprite_wire(&mut value);
scrub_removed_text_outline_fields(&mut value);
if let Some(detail) = invalid_position_style_detail(&value) {
return Err(CommandError::msg(format!("invalid-preset: {detail}")));
@@ -61,6 +63,16 @@ pub(super) fn invalid_position_style_detail(preset: &serde_json::Value) -> Optio
let Some(entry) = entry.as_object() else {
continue;
};
+ if let Some(value) = entry.get("rotation") {
+ if !value.as_f64().is_some_and(|rotation| {
+ rotation.is_finite()
+ && (ELEMENT_ROTATION_MIN..=ELEMENT_ROTATION_MAX).contains(&rotation)
+ }) {
+ return Some(format!(
+ "{collection_name}[{mode:?}][{index}].rotation: must be a finite number between {ELEMENT_ROTATION_MIN} and {ELEMENT_ROTATION_MAX}"
+ ));
+ }
+ }
for field in ELEMENT_FIELDS {
let error = match field {
"noteBorderGradient" => invalid_note_gradient_error(
diff --git a/src-tauri/src/commands/preset/mod.rs b/src-tauri/src/commands/preset/mod.rs
index 9fcd1181c..9a2e0deaa 100644
--- a/src-tauri/src/commands/preset/mod.rs
+++ b/src-tauri/src/commands/preset/mod.rs
@@ -9,7 +9,8 @@ use serde::{Deserialize, Serialize};
pub(crate) use crate::models::BUILTIN_TAB_IDS;
use crate::models::{
CustomCss, CustomJs, CustomTab, FontSettings, GraphPositions, KeyMappings, KeyPositions,
- KnobPositions, LayerGroups, NoteSettings, StatPositions, TabCssOverrides, TabNoteOverrides,
+ KnobPositions, LayerGroups, NoteSettings, SpritePositions, StatPositions, TabCssOverrides,
+ TabNoteOverrides,
};
use crate::state::assets::local_asset_path::{file_url_to_path, FileUrlPath};
@@ -29,6 +30,7 @@ pub struct PresetSnapshot {
pub stat_positions: StatPositions,
pub graph_positions: GraphPositions,
pub knob_positions: KnobPositions,
+ pub sprite_positions: SpritePositions,
pub custom_tabs: Vec,
pub tab_order: Vec,
pub bar_count: u8,
@@ -45,6 +47,8 @@ pub(crate) struct PresetFile {
pub graph_positions: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub knob_positions: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub sprite_positions: Option,
pub background_color: Option,
pub note_settings: Option,
pub note_effect: Option,
diff --git a/src-tauri/src/commands/preset/save.rs b/src-tauri/src/commands/preset/save.rs
index bfbd4ef1f..c7f74c49c 100644
--- a/src-tauri/src/commands/preset/save.rs
+++ b/src-tauri/src/commands/preset/save.rs
@@ -11,8 +11,9 @@ use crate::{
commands::dialog::parented_file_dialog,
errors::{CmdResult, CommandError},
models::{
- FontSettings, FontType, GraphPositions, KeyMappings, KeyPositions, KnobPositions,
- LayerGroups, StatPositions, TabCssOverrides, TabNoteOverrides,
+ rewrite_coupled_sprite_image_reference, FontSettings, FontType, GraphPositions,
+ KeyMappings, KeyPositions, KnobPositions, LayerGroups, SpritePositions, StatPositions,
+ TabCssOverrides, TabNoteOverrides,
},
state::{atomic_file::atomic_replace, AppState},
};
@@ -58,13 +59,20 @@ fn preset_save_from_path(app: AppHandle, path: PathBuf) -> CmdResult CmdResult CmdResult CmdResult CmdResult CmdResult<(
KeyPositions,
StatPositions,
GraphPositions,
KnobPositions,
+ SpritePositions,
Vec,
)> {
let mut exported_key_positions = key_positions.clone();
let mut exported_stat_positions = stat_positions.clone();
let mut exported_graph_positions = graph_positions.clone();
let mut exported_knob_positions = knob_positions.clone();
+ let mut exported_sprite_positions = sprite_positions.clone();
let mut embedded_local_images = Vec::new();
let mut path_to_image_id: HashMap = HashMap::new();
@@ -486,11 +505,33 @@ fn build_preset_image_payload(
}
}
+ for sprites in exported_sprite_positions.values_mut() {
+ for sprite in sprites {
+ rewrite_coupled_sprite_image_reference(sprite, |image_ref| {
+ rewrite_position_image_reference(
+ image_ref,
+ &mut embedded_local_images,
+ &mut path_to_image_id,
+ )
+ })?;
+ for pose in &mut sprite.poses {
+ rewrite_coupled_sprite_image_reference(pose, |image_ref| {
+ rewrite_position_image_reference(
+ image_ref,
+ &mut embedded_local_images,
+ &mut path_to_image_id,
+ )
+ })?;
+ }
+ }
+ }
+
Ok((
exported_key_positions,
exported_stat_positions,
exported_graph_positions,
exported_knob_positions,
+ exported_sprite_positions,
embedded_local_images,
))
}
@@ -705,11 +746,12 @@ mod tests {
};
let key_positions = KeyPositions::from([("4key".to_string(), vec![position])]);
- let (exported, _, _, _, embedded) = build_preset_image_payload(
+ let (exported, _, _, _, _, embedded) = build_preset_image_payload(
&key_positions,
&StatPositions::new(),
&GraphPositions::new(),
&KnobPositions::new(),
+ &SpritePositions::new(),
)
.unwrap();
diff --git a/src-tauri/src/commands/preset/tests.rs b/src-tauri/src/commands/preset/tests.rs
index 74918de72..232e1fe0f 100644
--- a/src-tauri/src/commands/preset/tests.rs
+++ b/src-tauri/src/commands/preset/tests.rs
@@ -1,11 +1,128 @@
-use super::PresetFile;
+use super::{PresetFile, PresetSnapshot};
// file URL 경로 테스트가 유닉스 전용이라 Windows에선 미사용 경고 방지
#[cfg(not(target_os = "windows"))]
use super::local_source_path_from_image_ref;
-use crate::models::{KeyCounterColor, KeySlot, NoteColor};
+use crate::models::{
+ default_bar_count, KeyCounterColor, KeySlot, NoteColor, ReactiveSpritePosition, SpritePose,
+ SpritePositions,
+};
use serde::{Deserialize, Serialize};
use serde_json::json;
+// 스냅샷 wire 키 집합 고정 - 프론트 공개 타입 PresetSnapshot과 같은 계약
+#[test]
+fn preset_snapshot_serializes_expected_wire_keys() {
+ let snapshot = PresetSnapshot {
+ keys: Default::default(),
+ positions: Default::default(),
+ stat_positions: Default::default(),
+ graph_positions: Default::default(),
+ knob_positions: Default::default(),
+ sprite_positions: Default::default(),
+ custom_tabs: Vec::new(),
+ tab_order: Vec::new(),
+ bar_count: default_bar_count(),
+ selected_key_type: "4key".to_string(),
+ tab_note_overrides: Default::default(),
+ };
+ let value = serde_json::to_value(&snapshot).expect("snapshot serializes");
+ let mut keys: Vec<&str> = value
+ .as_object()
+ .expect("snapshot is an object")
+ .keys()
+ .map(String::as_str)
+ .collect();
+ keys.sort_unstable();
+ assert_eq!(
+ keys,
+ [
+ "barCount",
+ "customTabs",
+ "graphPositions",
+ "keys",
+ "knobPositions",
+ "positions",
+ "selectedKeyType",
+ "spritePositions",
+ "statPositions",
+ "tabNoteOverrides",
+ "tabOrder",
+ ]
+ );
+}
+
+#[test]
+fn preset_sprite_wire_keys_include_oneshot_fields_and_exclude_removed_match_mode() {
+ let snapshot = PresetSnapshot {
+ sprite_positions: SpritePositions::from([(
+ "4key".to_string(),
+ vec![ReactiveSpritePosition {
+ id: uuid::Uuid::new_v4().to_string(),
+ rotation: 45.5,
+ poses: vec![SpritePose {
+ pose_id: uuid::Uuid::new_v4().to_string(),
+ triggers: vec![uuid::Uuid::new_v4().to_string()],
+ ..SpritePose::default()
+ }],
+ ..ReactiveSpritePosition::default()
+ }],
+ )]),
+ keys: Default::default(),
+ positions: Default::default(),
+ stat_positions: Default::default(),
+ graph_positions: Default::default(),
+ knob_positions: Default::default(),
+ custom_tabs: Vec::new(),
+ tab_order: Vec::new(),
+ bar_count: default_bar_count(),
+ selected_key_type: "4key".to_string(),
+ tab_note_overrides: Default::default(),
+ };
+ let value = serde_json::to_value(snapshot).unwrap();
+ let sprite = value["spritePositions"]["4key"][0].as_object().unwrap();
+ let mut sprite_keys = sprite.keys().map(String::as_str).collect::>();
+ sprite_keys.sort_unstable();
+ assert_eq!(
+ sprite_keys,
+ [
+ "activation",
+ "baseImage",
+ "className",
+ "dx",
+ "dy",
+ "height",
+ "hidden",
+ "id",
+ "idleTransform",
+ "pivot",
+ "poses",
+ "pressDurationMs",
+ "referenceNaturalSize",
+ "rotation",
+ "transitionEasing",
+ "transitionMs",
+ "useInlineStyles",
+ "width",
+ "zIndex",
+ ]
+ );
+ assert_eq!(sprite["rotation"], 45.5);
+ let pose = sprite["poses"][0].as_object().unwrap();
+ let mut pose_keys = pose.keys().map(String::as_str).collect::>();
+ pose_keys.sort_unstable();
+ assert_eq!(
+ pose_keys,
+ [
+ "imageOverride",
+ "imageOverrideMetrics",
+ "pivot",
+ "poseId",
+ "transform",
+ "triggers"
+ ]
+ );
+}
+
#[test]
fn preset_round_trip_preserves_layer_groups_and_tab_css_overrides() {
let value = json!({
diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs
index 6861832a2..4a149b3f0 100644
--- a/src-tauri/src/errors.rs
+++ b/src-tauri/src/errors.rs
@@ -283,6 +283,7 @@ mod tests {
"TOO_MANY_LAYER_GROUPS",
"TOO_MANY_MODES",
"TOO_MANY_PLUGIN_INSTANCES",
+ "TOO_MANY_SPRITE_POSE_TRIGGERS",
"TOO_MANY_RENDER_ITEMS",
"TOO_MANY_SLOTS_PER_MEMBER",
];
diff --git a/src-tauri/src/models/bootstrap.rs b/src-tauri/src/models/bootstrap.rs
index 8d7887dde..ccee5a615 100644
--- a/src-tauri/src/models/bootstrap.rs
+++ b/src-tauri/src/models/bootstrap.rs
@@ -18,6 +18,7 @@ pub struct BootstrapPayload {
pub stat_positions: StatPositions,
pub graph_positions: GraphPositions,
pub knob_positions: KnobPositions,
+ pub sprite_positions: SpritePositions,
pub custom_tabs: Vec,
pub tab_order: Vec,
pub bar_count: u8,
diff --git a/src-tauri/src/models/editor.rs b/src-tauri/src/models/editor.rs
index 5761d15f2..19b1c3ae3 100644
--- a/src-tauri/src/models/editor.rs
+++ b/src-tauri/src/models/editor.rs
@@ -1,15 +1,18 @@
-use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+
+use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize};
+use serde_json::Value;
use super::{
AppStoreData, GradientSpec, GradientStop, GraphPosition, GraphPositions, GraphType, ImageFit,
ImageMode, KeyCounterAlign, KeyCounterAlignMode, KeyCounterPlacement, KeyCounters, KeyMappings,
KeyPosition, KeyPositions, KeySlot, KnobPosition, KnobPositions, LayerGroups, NoteAlignment,
- SlotMatch, StatPosition, StatPositions, StatType,
+ SlotMatch, SpritePositions, StatPosition, StatPositions, StatType,
};
pub const EDITOR_SCHEMA_VERSION: u16 = 1;
pub const EDITOR_COMMIT_SCHEMA_VERSION_V2: u16 = 2;
-pub const EDITOR_OPS_VERSION: u16 = 2;
+pub const EDITOR_OPS_VERSION: u16 = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -19,6 +22,7 @@ pub enum EditorField {
StatPositions,
GraphPositions,
KnobPositions,
+ SpritePositions,
LayerGroups,
}
@@ -31,6 +35,8 @@ pub struct EditorDocumentV1 {
pub stat_positions: StatPositions,
pub graph_positions: GraphPositions,
pub knob_positions: KnobPositions,
+ #[serde(default)]
+ pub sprite_positions: SpritePositions,
pub layer_groups: LayerGroups,
}
@@ -43,6 +49,7 @@ impl EditorDocumentV1 {
stat_positions: store.stat_positions.clone(),
graph_positions: store.graph_positions.clone(),
knob_positions: store.knob_positions.clone(),
+ sprite_positions: store.sprite_positions.clone(),
layer_groups: store.layer_groups.clone(),
}
}
@@ -53,6 +60,7 @@ impl EditorDocumentV1 {
store.stat_positions = self.stat_positions.clone();
store.graph_positions = self.graph_positions.clone();
store.knob_positions = self.knob_positions.clone();
+ store.sprite_positions = self.sprite_positions.clone();
store.layer_groups = self.layer_groups.clone();
}
@@ -72,6 +80,9 @@ impl EditorDocumentV1 {
if let Some(value) = patch.knob_positions.as_ref() {
self.knob_positions = value.clone();
}
+ if let Some(value) = patch.sprite_positions.as_ref() {
+ self.sprite_positions = value.clone();
+ }
if let Some(value) = patch.layer_groups.as_ref() {
self.layer_groups = value.clone();
}
@@ -94,6 +105,9 @@ impl EditorDocumentV1 {
if self.knob_positions != next.knob_positions {
fields.push(EditorField::KnobPositions);
}
+ if self.sprite_positions != next.sprite_positions {
+ fields.push(EditorField::SpritePositions);
+ }
if self.layer_groups != next.layer_groups {
fields.push(EditorField::LayerGroups);
}
@@ -117,6 +131,9 @@ impl EditorDocumentV1 {
EditorField::KnobPositions => {
patch.knob_positions = Some(self.knob_positions.clone());
}
+ EditorField::SpritePositions => {
+ patch.sprite_positions = Some(self.sprite_positions.clone());
+ }
EditorField::LayerGroups => {
patch.layer_groups = Some(self.layer_groups.clone());
}
@@ -126,7 +143,116 @@ impl EditorDocumentV1 {
}
}
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct SpritePosePatchPresence {
+ pivot: bool,
+ image_override: bool,
+ image_override_metrics: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct SpritePositionPatchPresence {
+ rotation: bool,
+ base_image: bool,
+ reference_natural_size: bool,
+ activation: bool,
+ press_duration_ms: bool,
+ poses: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[serde(transparent)]
+pub(crate) struct SpritePositionsPatchPresence(HashMap>);
+
+impl SpritePositionsPatchPresence {
+ fn from_patch_value(value: &Value) -> Option {
+ let modes = value.get("spritePositions")?.as_object()?;
+ let presence = modes
+ .iter()
+ .map(|(mode, sprites)| {
+ let sprites = sprites
+ .as_array()
+ .map(|sprites| {
+ sprites
+ .iter()
+ .map(|sprite| {
+ let object = sprite.as_object();
+ let poses = object
+ .and_then(|sprite| sprite.get("poses"))
+ .and_then(Value::as_array)
+ .map(|poses| {
+ poses
+ .iter()
+ .map(|pose| {
+ let pose = pose.as_object();
+ SpritePosePatchPresence {
+ pivot: pose.is_some_and(|pose| {
+ pose.contains_key("pivot")
+ }),
+ image_override: pose.is_some_and(|pose| {
+ pose.contains_key("imageOverride")
+ }),
+ image_override_metrics: pose.is_some_and(
+ |pose| {
+ pose.contains_key(
+ "imageOverrideMetrics",
+ )
+ },
+ ),
+ }
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+ SpritePositionPatchPresence {
+ rotation: object
+ .is_some_and(|sprite| sprite.contains_key("rotation")),
+ base_image: object
+ .is_some_and(|sprite| sprite.contains_key("baseImage")),
+ reference_natural_size: object.is_some_and(|sprite| {
+ sprite.contains_key("referenceNaturalSize")
+ }),
+ activation: object
+ .is_some_and(|sprite| sprite.contains_key("activation")),
+ press_duration_ms: object.is_some_and(|sprite| {
+ sprite.contains_key("pressDurationMs")
+ }),
+ poses,
+ }
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+ (mode.clone(), sprites)
+ })
+ .collect();
+ Some(Self(presence))
+ }
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct EditorPatchV1Input {
+ schema_version: u16,
+ #[serde(default)]
+ keys: Option,
+ #[serde(default)]
+ key_positions: Option,
+ #[serde(default)]
+ stat_positions: Option,
+ #[serde(default)]
+ graph_positions: Option,
+ #[serde(default)]
+ knob_positions: Option,
+ #[serde(default)]
+ sprite_positions: Option,
+ #[serde(default)]
+ layer_groups: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorPatchV1 {
pub schema_version: u16,
@@ -141,7 +267,33 @@ pub struct EditorPatchV1 {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub knob_positions: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
+ pub sprite_positions: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
pub layer_groups: Option,
+ #[serde(skip)]
+ pub(crate) sprite_positions_presence: Option,
+}
+
+impl<'de> Deserialize<'de> for EditorPatchV1 {
+ fn deserialize(deserializer: D) -> Result
+ where
+ D: Deserializer<'de>,
+ {
+ let value = Value::deserialize(deserializer)?;
+ let sprite_positions_presence = SpritePositionsPatchPresence::from_patch_value(&value);
+ let input = EditorPatchV1Input::deserialize(value).map_err(D::Error::custom)?;
+ Ok(Self {
+ schema_version: input.schema_version,
+ keys: input.keys,
+ key_positions: input.key_positions,
+ stat_positions: input.stat_positions,
+ graph_positions: input.graph_positions,
+ knob_positions: input.knob_positions,
+ sprite_positions: input.sprite_positions,
+ layer_groups: input.layer_groups,
+ sprite_positions_presence,
+ })
+ }
}
impl Default for EditorPatchV1 {
@@ -153,7 +305,9 @@ impl Default for EditorPatchV1 {
stat_positions: None,
graph_positions: None,
knob_positions: None,
+ sprite_positions: None,
layer_groups: None,
+ sprite_positions_presence: None,
}
}
}
@@ -166,6 +320,7 @@ impl EditorPatchV1 {
EditorField::StatPositions => self.stat_positions.is_some(),
EditorField::GraphPositions => self.graph_positions.is_some(),
EditorField::KnobPositions => self.knob_positions.is_some(),
+ EditorField::SpritePositions => self.sprite_positions.is_some(),
EditorField::LayerGroups => self.layer_groups.is_some(),
}
}
@@ -177,12 +332,100 @@ impl EditorPatchV1 {
EditorField::StatPositions,
EditorField::GraphPositions,
EditorField::KnobPositions,
+ EditorField::SpritePositions,
EditorField::LayerGroups,
]
.into_iter()
.filter(|field| self.includes(*field))
.collect()
}
+
+ pub(crate) fn merge_omitted_sprite_fields(&mut self, current: &SpritePositions) {
+ let (Some(candidate), Some(presence)) = (
+ self.sprite_positions.as_mut(),
+ self.sprite_positions_presence.as_ref(),
+ ) else {
+ return;
+ };
+
+ for (mode, candidate_sprites) in candidate {
+ let Some(sprite_presence) = presence.0.get(mode) else {
+ continue;
+ };
+ for (sprite, sprite_presence) in candidate_sprites.iter_mut().zip(sprite_presence) {
+ if sprite.id.is_empty() {
+ continue;
+ }
+ let Some(current_sprite) = current
+ .values()
+ .flatten()
+ .find(|current_sprite| current_sprite.id == sprite.id)
+ else {
+ continue;
+ };
+
+ if !sprite_presence.rotation {
+ sprite.rotation = current_sprite.rotation;
+ }
+ if !sprite_presence.base_image {
+ sprite.base_image = current_sprite.base_image.clone();
+ }
+ if !sprite_presence.reference_natural_size {
+ sprite.reference_natural_size = if sprite_presence.base_image
+ && sprite.base_image != current_sprite.base_image
+ {
+ if sprite.base_image.is_none() {
+ current_sprite
+ .reference_natural_size
+ .clone()
+ .map(|mut reference| {
+ reference.source = None;
+ reference
+ })
+ } else {
+ None
+ }
+ } else {
+ current_sprite.reference_natural_size.clone()
+ };
+ }
+ if !sprite_presence.activation {
+ sprite.activation = current_sprite.activation;
+ }
+ if !sprite_presence.press_duration_ms {
+ sprite.press_duration_ms = current_sprite.press_duration_ms;
+ }
+
+ for (pose, pose_presence) in sprite.poses.iter_mut().zip(&sprite_presence.poses) {
+ if pose.pose_id.is_empty() {
+ continue;
+ }
+ let Some(current_pose) = current_sprite
+ .poses
+ .iter()
+ .find(|current_pose| current_pose.pose_id == pose.pose_id)
+ else {
+ continue;
+ };
+ if !pose_presence.pivot {
+ pose.pivot = current_pose.pivot;
+ }
+ if !pose_presence.image_override {
+ pose.image_override = current_pose.image_override.clone();
+ }
+ if !pose_presence.image_override_metrics {
+ pose.image_override_metrics = if pose_presence.image_override
+ && pose.image_override != current_pose.image_override
+ {
+ None
+ } else {
+ current_pose.image_override_metrics.clone()
+ };
+ }
+ }
+ }
+ }
+ }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -192,6 +435,7 @@ pub enum EditorElementTypeV1 {
Stat,
Graph,
Knob,
+ Sprite,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
@@ -293,6 +537,7 @@ pub enum EditorElementPropertyPatchV1 {
NoteAutoYCorrection(bool),
NoteAlignment(NoteAlignment),
NoteBorderSide(EditorNoteBorderSideV1),
+ Rotation(f64),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -523,6 +768,10 @@ pub enum EditorOpV1 {
id: String,
bounds: EditorBoundsV1,
},
+ ResizeSprite {
+ id: String,
+ bounds: EditorBoundsV1,
+ },
DeleteElement {
#[serde(rename = "elementType")]
element_type: EditorElementTypeV1,
@@ -578,6 +827,7 @@ impl EditorOpV1 {
.iter()
.any(|element| matches!(element, EditorFrozenElementV1::Key { .. })),
Self::SetBounds { .. }
+ | Self::ResizeSprite { .. }
| Self::PatchElement { .. }
| Self::ReorderElements { .. }
| Self::SetElementGroups { .. }
@@ -588,6 +838,7 @@ impl EditorOpV1 {
pub fn target_id(&self) -> Option<&str> {
match self {
Self::SetBounds { id, .. }
+ | Self::ResizeSprite { id, .. }
| Self::DeleteElement { id, .. }
| Self::PatchElement { id, .. }
| Self::SetKeySlot { id, .. } => Some(id),
@@ -638,6 +889,9 @@ pub enum EditorFrozenElementV1 {
Knob {
position: KnobPosition,
},
+ Sprite {
+ position: super::ReactiveSpritePosition,
+ },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -674,15 +928,17 @@ impl EditorFrozenElementV1 {
Self::Stat { position } => &position.position.id,
Self::Graph { position } => &position.position.id,
Self::Knob { position } => &position.position.id,
+ Self::Sprite { position } => &position.id,
}
}
- pub(crate) fn position_mut(&mut self) -> &mut KeyPosition {
+ pub(crate) fn key_position_mut(&mut self) -> Option<&mut KeyPosition> {
match self {
- Self::Key { position, .. } => position,
- Self::Stat { position } => &mut position.position,
- Self::Graph { position } => &mut position.position,
- Self::Knob { position } => &mut position.position,
+ Self::Key { position, .. } => Some(position),
+ Self::Stat { position } => Some(&mut position.position),
+ Self::Graph { position } => Some(&mut position.position),
+ Self::Knob { position } => Some(&mut position.position),
+ Self::Sprite { .. } => None,
}
}
}
diff --git a/src-tauri/src/models/editor/tests.rs b/src-tauri/src/models/editor/tests.rs
index 76f972d7e..b4b0a9f90 100644
--- a/src-tauri/src/models/editor/tests.rs
+++ b/src-tauri/src/models/editor/tests.rs
@@ -1,9 +1,395 @@
use super::*;
+use crate::models::{
+ ReactiveSpritePosition, SpriteActivation, SpriteAnchor, SpriteImageMetrics, SpritePose,
+ SpriteReferenceNaturalSize,
+};
// TS canonical 배열과 공유하는 property 태그 fixture
const PROPERTY_TAG_FIXTURE: &str =
include_str!("../../../../tests/fixtures/editor-property-tags.json");
+fn sprite_with_pose() -> ReactiveSpritePosition {
+ ReactiveSpritePosition {
+ id: uuid::Uuid::new_v4().to_string(),
+ poses: vec![SpritePose {
+ pose_id: uuid::Uuid::new_v4().to_string(),
+ triggers: vec![uuid::Uuid::new_v4().to_string()],
+ ..SpritePose::default()
+ }],
+ ..ReactiveSpritePosition::default()
+ }
+}
+
+fn insert_sprite_activation_and_removed_match_mode(value: &mut serde_json::Value, pointer: &str) {
+ let sprite = value
+ .pointer_mut(pointer)
+ .and_then(serde_json::Value::as_object_mut)
+ .expect("sprite object exists");
+ sprite.insert("activation".to_string(), serde_json::json!("onPress"));
+ sprite
+ .get_mut("poses")
+ .and_then(serde_json::Value::as_array_mut)
+ .and_then(|poses| poses.first_mut())
+ .expect("pose exists")
+ .as_object_mut()
+ .expect("pose object exists")
+ .insert("matchMode".to_string(), serde_json::json!("exact"));
+}
+
+fn assert_sprite_activation_round_trips_and_match_mode_is_omitted(
+ value: &serde_json::Value,
+ pointer: &str,
+) {
+ let sprite = value
+ .pointer(pointer)
+ .and_then(serde_json::Value::as_object)
+ .expect("sprite object exists");
+ assert_eq!(sprite["activation"], serde_json::json!("onPress"));
+ assert!(!sprite["poses"][0]
+ .as_object()
+ .expect("pose object exists")
+ .contains_key("matchMode"));
+}
+
+#[test]
+fn v1_editor_patch_round_trips_sprite_activation_and_ignores_removed_match_mode() {
+ let mut sprite_positions = SpritePositions::new();
+ sprite_positions.insert("4key".to_string(), vec![sprite_with_pose()]);
+ let mut value = serde_json::to_value(EditorPatchV1 {
+ sprite_positions: Some(sprite_positions),
+ ..EditorPatchV1::default()
+ })
+ .unwrap();
+ insert_sprite_activation_and_removed_match_mode(&mut value, "/spritePositions/4key/0");
+
+ let restored: EditorPatchV1 = serde_json::from_value(value).unwrap();
+ let serialized = serde_json::to_value(restored).unwrap();
+
+ assert_sprite_activation_round_trips_and_match_mode_is_omitted(
+ &serialized,
+ "/spritePositions/4key/0",
+ );
+}
+
+#[test]
+fn changes_presence_merge_preserves_omitted_existing_sprite_fields() {
+ for schema_version in [EDITOR_SCHEMA_VERSION, EDITOR_COMMIT_SCHEMA_VERSION_V2] {
+ let sprite_id = uuid::Uuid::new_v4().to_string();
+ let pose_id = uuid::Uuid::new_v4().to_string();
+ let current_sprite = ReactiveSpritePosition {
+ id: sprite_id,
+ rotation: -45.5,
+ base_image: Some("/images/base.png".to_string()),
+ reference_natural_size: Some(SpriteReferenceNaturalSize {
+ source: Some("/images/base.png".to_string()),
+ width: 800,
+ height: 600,
+ }),
+ activation: SpriteActivation::OnPress,
+ press_duration_ms: 900,
+ poses: vec![SpritePose {
+ pose_id,
+ pivot: Some(SpriteAnchor { x: 0.2, y: 0.8 }),
+ image_override: Some("/images/pose.png".to_string()),
+ image_override_metrics: Some(SpriteImageMetrics {
+ source: "/images/pose.png".to_string(),
+ width: 320,
+ height: 240,
+ }),
+ ..SpritePose::default()
+ }],
+ ..ReactiveSpritePosition::default()
+ };
+ let mut current = SpritePositions::new();
+ current.insert("4key".to_string(), vec![current_sprite.clone()]);
+ let mut candidate = current_sprite;
+ candidate.transition_easing = "linear".to_string();
+ let mut raw = serde_json::to_value(EditorPatchV1 {
+ schema_version,
+ sprite_positions: Some(HashMap::from([("4key".to_string(), vec![candidate])])),
+ ..EditorPatchV1::default()
+ })
+ .unwrap();
+ let sprite = raw
+ .pointer_mut("/spritePositions/4key/0")
+ .and_then(Value::as_object_mut)
+ .unwrap();
+ for field in [
+ "rotation",
+ "baseImage",
+ "referenceNaturalSize",
+ "activation",
+ "pressDurationMs",
+ ] {
+ sprite.remove(field);
+ }
+ let pose = sprite["poses"][0].as_object_mut().unwrap();
+ pose.remove("pivot");
+ pose.remove("imageOverride");
+ pose.remove("imageOverrideMetrics");
+
+ let mut patch: EditorPatchV1 = serde_json::from_value(raw).unwrap();
+ patch.merge_omitted_sprite_fields(¤t);
+ let merged = &patch.sprite_positions.as_ref().unwrap()["4key"][0];
+ let expected = ¤t["4key"][0];
+ assert_eq!(merged.rotation, expected.rotation);
+ assert_eq!(merged.base_image, expected.base_image);
+ assert_eq!(
+ merged.reference_natural_size,
+ expected.reference_natural_size
+ );
+ assert_eq!(merged.activation, expected.activation);
+ assert_eq!(merged.press_duration_ms, expected.press_duration_ms);
+ assert_eq!(merged.poses[0].pivot, expected.poses[0].pivot);
+ assert_eq!(
+ merged.poses[0].image_override,
+ expected.poses[0].image_override
+ );
+ assert_eq!(
+ merged.poses[0].image_override_metrics,
+ expected.poses[0].image_override_metrics
+ );
+ assert_eq!(merged.transition_easing, "linear");
+ }
+}
+
+#[test]
+fn changes_presence_merge_keeps_new_defaults_and_applies_nullable_nulls() {
+ let mut existing = sprite_with_pose();
+ existing.rotation = 45.5;
+ existing.reference_natural_size = Some(SpriteReferenceNaturalSize {
+ source: Some("/images/base.png".to_string()),
+ width: 800,
+ height: 600,
+ });
+ existing.base_image = Some("/images/base.png".to_string());
+ existing.poses[0].image_override = Some("/images/pose.png".to_string());
+ existing.poses[0].pivot = Some(SpriteAnchor { x: 0.2, y: 0.8 });
+ existing.poses[0].image_override_metrics = Some(SpriteImageMetrics {
+ source: "/images/pose.png".to_string(),
+ width: 320,
+ height: 240,
+ });
+ let mut current = SpritePositions::new();
+ current.insert("4key".to_string(), vec![existing.clone()]);
+
+ let mut raw = serde_json::to_value(EditorPatchV1 {
+ sprite_positions: Some(HashMap::from([(
+ "4key".to_string(),
+ vec![existing.clone(), sprite_with_pose()],
+ )])),
+ ..EditorPatchV1::default()
+ })
+ .unwrap();
+ let sprites = raw["spritePositions"]["4key"].as_array_mut().unwrap();
+ let existing_raw = sprites[0].as_object_mut().unwrap();
+ existing_raw.insert("rotation".to_string(), serde_json::json!(0.0));
+ existing_raw.insert("referenceNaturalSize".to_string(), Value::Null);
+ let existing_pose = existing_raw["poses"][0].as_object_mut().unwrap();
+ existing_pose.insert("pivot".to_string(), Value::Null);
+ existing_pose.insert("imageOverrideMetrics".to_string(), Value::Null);
+ let new_raw = sprites[1].as_object_mut().unwrap();
+ for field in [
+ "rotation",
+ "referenceNaturalSize",
+ "activation",
+ "pressDurationMs",
+ ] {
+ new_raw.remove(field);
+ }
+ let new_pose = new_raw["poses"][0].as_object_mut().unwrap();
+ new_pose.remove("imageOverrideMetrics");
+
+ let mut patch: EditorPatchV1 = serde_json::from_value(raw).unwrap();
+ patch.merge_omitted_sprite_fields(¤t);
+ let sprites = &patch.sprite_positions.as_ref().unwrap()["4key"];
+ assert_eq!(sprites[0].rotation, 0.0);
+ assert_eq!(sprites[1].rotation, 0.0);
+ assert_eq!(sprites[0].reference_natural_size, None);
+ assert_eq!(sprites[0].poses[0].pivot, None);
+ assert_eq!(sprites[0].poses[0].image_override_metrics, None);
+ assert_eq!(sprites[1].reference_natural_size, None);
+ assert_eq!(sprites[1].activation, SpriteActivation::WhileHeld);
+ assert_eq!(sprites[1].press_duration_ms, 300);
+}
+
+#[test]
+fn changes_presence_clears_omitted_metrics_when_image_paths_change() {
+ for schema_version in [EDITOR_SCHEMA_VERSION, EDITOR_COMMIT_SCHEMA_VERSION_V2] {
+ let mut existing = sprite_with_pose();
+ existing.base_image = Some("/images/base-a.png".to_string());
+ existing.reference_natural_size = Some(SpriteReferenceNaturalSize {
+ source: Some("/images/base-a.png".to_string()),
+ width: 800,
+ height: 600,
+ });
+ existing.poses[0].image_override = Some("/images/pose-a.png".to_string());
+ existing.poses[0].image_override_metrics = Some(SpriteImageMetrics {
+ source: "/images/pose-a.png".to_string(),
+ width: 320,
+ height: 240,
+ });
+ let current = HashMap::from([("4key".to_string(), vec![existing.clone()])]);
+
+ let mut changed = existing;
+ changed.base_image = Some("/images/base-b.png".to_string());
+ changed.poses[0].image_override = Some("/images/pose-b.png".to_string());
+ let mut raw = serde_json::to_value(EditorPatchV1 {
+ schema_version,
+ sprite_positions: Some(HashMap::from([("4key".to_string(), vec![changed])])),
+ ..EditorPatchV1::default()
+ })
+ .unwrap();
+ raw["spritePositions"]["4key"][0]
+ .as_object_mut()
+ .unwrap()
+ .remove("referenceNaturalSize");
+ raw["spritePositions"]["4key"][0]["poses"][0]
+ .as_object_mut()
+ .unwrap()
+ .remove("imageOverrideMetrics");
+
+ let mut patch: EditorPatchV1 = serde_json::from_value(raw).unwrap();
+ patch.merge_omitted_sprite_fields(¤t);
+ let sprite = &patch.sprite_positions.unwrap()["4key"][0];
+ assert_eq!(sprite.reference_natural_size, None);
+ assert_eq!(sprite.poses[0].image_override_metrics, None);
+ }
+}
+
+#[test]
+fn changes_presence_clears_omitted_reference_when_base_image_becomes_whitespace() {
+ for schema_version in [EDITOR_SCHEMA_VERSION, EDITOR_COMMIT_SCHEMA_VERSION_V2] {
+ let mut existing = sprite_with_pose();
+ existing.base_image = Some("/images/base.png".to_string());
+ existing.reference_natural_size = Some(SpriteReferenceNaturalSize {
+ source: Some("/images/base.png".to_string()),
+ width: 100,
+ height: 100,
+ });
+ let current = HashMap::from([("4key".to_string(), vec![existing.clone()])]);
+ existing.base_image = Some(" ".to_string());
+ let mut raw = serde_json::to_value(EditorPatchV1 {
+ schema_version,
+ sprite_positions: Some(HashMap::from([("4key".to_string(), vec![existing])])),
+ ..EditorPatchV1::default()
+ })
+ .unwrap();
+ raw["spritePositions"]["4key"][0]
+ .as_object_mut()
+ .unwrap()
+ .remove("referenceNaturalSize");
+
+ let mut patch: EditorPatchV1 = serde_json::from_value(raw).unwrap();
+ patch.merge_omitted_sprite_fields(¤t);
+ assert_eq!(
+ patch.sprite_positions.unwrap()["4key"][0].reference_natural_size,
+ None
+ );
+ }
+}
+
+#[test]
+fn changes_presence_keeps_size_with_null_source_when_base_image_is_cleared() {
+ for schema_version in [EDITOR_SCHEMA_VERSION, EDITOR_COMMIT_SCHEMA_VERSION_V2] {
+ let mut existing = sprite_with_pose();
+ existing.base_image = Some("/images/base.png".to_string());
+ existing.reference_natural_size = Some(SpriteReferenceNaturalSize {
+ source: Some("/images/base.png".to_string()),
+ width: 800,
+ height: 600,
+ });
+ let current = HashMap::from([("4key".to_string(), vec![existing.clone()])]);
+ existing.base_image = None;
+ let mut raw = serde_json::to_value(EditorPatchV1 {
+ schema_version,
+ sprite_positions: Some(HashMap::from([("4key".to_string(), vec![existing])])),
+ ..EditorPatchV1::default()
+ })
+ .unwrap();
+ raw["spritePositions"]["4key"][0]
+ .as_object_mut()
+ .unwrap()
+ .remove("referenceNaturalSize");
+
+ let mut patch: EditorPatchV1 = serde_json::from_value(raw).unwrap();
+ patch.merge_omitted_sprite_fields(¤t);
+ let reference = patch.sprite_positions.unwrap()["4key"][0]
+ .reference_natural_size
+ .clone()
+ .unwrap();
+ assert_eq!(reference.source, None);
+ assert_eq!((reference.width, reference.height), (800, 600));
+ }
+}
+
+#[test]
+fn changes_presence_rejects_non_nullable_nulls() {
+ let sprite = sprite_with_pose();
+ let raw = serde_json::to_value(EditorPatchV1 {
+ sprite_positions: Some(HashMap::from([("4key".to_string(), vec![sprite])])),
+ ..EditorPatchV1::default()
+ })
+ .unwrap();
+
+ for pointer in [
+ "/spritePositions/4key/0/activation",
+ "/spritePositions/4key/0/pressDurationMs",
+ ] {
+ let mut invalid = raw.clone();
+ *invalid.pointer_mut(pointer).unwrap() = Value::Null;
+ assert!(serde_json::from_value::(invalid).is_err());
+ }
+}
+
+#[test]
+fn frozen_sprite_insert_round_trips_activation_and_ignores_removed_match_mode() {
+ let mut value = serde_json::to_value(EditorOpV1::InsertFrozenElements {
+ mode: "4key".to_string(),
+ elements: vec![EditorFrozenElementV1::Sprite {
+ position: sprite_with_pose(),
+ }],
+ groups: Vec::new(),
+ z_updates: Vec::new(),
+ })
+ .unwrap();
+ insert_sprite_activation_and_removed_match_mode(&mut value, "/elements/0/position");
+
+ let restored: EditorOpV1 = serde_json::from_value(value).unwrap();
+ let serialized = serde_json::to_value(restored).unwrap();
+
+ assert_sprite_activation_round_trips_and_match_mode_is_omitted(
+ &serialized,
+ "/elements/0/position",
+ );
+}
+
+#[test]
+fn v1_editor_patch_defaults_missing_sprite_oneshot_fields() {
+ let mut sprite_positions = SpritePositions::new();
+ sprite_positions.insert("4key".to_string(), vec![sprite_with_pose()]);
+ let mut value = serde_json::to_value(EditorPatchV1 {
+ sprite_positions: Some(sprite_positions),
+ ..EditorPatchV1::default()
+ })
+ .unwrap();
+ let sprite = value
+ .pointer_mut("/spritePositions/4key/0")
+ .and_then(serde_json::Value::as_object_mut)
+ .unwrap();
+ sprite.remove("activation");
+ sprite.remove("pressDurationMs");
+ let restored: EditorPatchV1 = serde_json::from_value(value).unwrap();
+ let serialized = serde_json::to_value(restored).unwrap();
+ let sprite = serialized
+ .pointer("/spritePositions/4key/0")
+ .and_then(serde_json::Value::as_object)
+ .unwrap();
+
+ assert_eq!(sprite["activation"], serde_json::json!("whileHeld"));
+ assert_eq!(sprite["pressDurationMs"], serde_json::json!(300));
+}
+
fn paint(color: &str) -> EditorPaintDescriptorV1 {
EditorPaintDescriptorV1 {
color: color.to_string(),
@@ -11,7 +397,7 @@ fn paint(color: &str) -> EditorPaintDescriptorV1 {
}
}
-// 75개 variant 전수: (patch, property 태그, value wire) 고정 표본
+// 76개 variant 전수: (patch, property 태그, value wire) 고정 표본
fn property_patch_samples() -> Vec<(
EditorElementPropertyPatchV1,
&'static str,
@@ -275,13 +661,14 @@ fn property_patch_samples() -> Vec<(
"noteBorderSide",
json!("vertical"),
),
+ (P::Rotation(-45.5), "rotation", json!(-45.5)),
]
}
#[test]
fn property_patch_wire_pins_all_tag_value_pairs_and_roundtrips() {
let samples = property_patch_samples();
- assert_eq!(samples.len(), 75);
+ assert_eq!(samples.len(), 76);
for (patch, tag, value) in samples {
let wire = serde_json::to_value(&patch).unwrap();
assert_eq!(
@@ -611,4 +998,16 @@ fn editor_request_detects_every_direct_key_mapping_mutation() {
}]),
)
.may_change_keys());
+ let sprite_id = uuid::Uuid::new_v4().to_string();
+ let resize = EditorOpV1::ResizeSprite {
+ id: sprite_id.clone(),
+ bounds: EditorBoundsV1 {
+ dx: 1.0,
+ dy: 2.0,
+ width: 200.0,
+ height: 100.0,
+ },
+ };
+ assert_eq!(resize.target_id(), Some(sprite_id.as_str()));
+ assert!(!request(None, Some(vec![resize])).may_change_keys());
}
diff --git a/src-tauri/src/models/element_positions.rs b/src-tauri/src/models/element_positions.rs
index cae2840af..51836b0c7 100644
--- a/src-tauri/src/models/element_positions.rs
+++ b/src-tauri/src/models/element_positions.rs
@@ -3,7 +3,7 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::{
- default_key_height, default_key_note_color, default_key_note_opacity,
+ default_element_rotation, default_key_height, default_key_note_color, default_key_note_opacity,
default_note_auto_y_correction, default_note_border_opacity, default_note_effect_enabled,
default_note_glow_enabled, default_note_glow_opacity, default_note_glow_size, GradientSpec,
ImageFit, KeyCounterSettings, NoteColor,
@@ -20,6 +20,7 @@ pub type KeyPositions = HashMap>;
pub type StatPositions = HashMap>;
pub type GraphPositions = HashMap>;
pub type KnobPositions = HashMap>;
+pub type SpritePositions = HashMap>;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
@@ -44,6 +45,9 @@ pub const IMAGE_TRANSFORM_ROTATION_MAX: f64 = 180.0;
pub const IMAGE_TRANSFORM_SCALE_MIN: f64 = 0.1;
pub const IMAGE_TRANSFORM_SCALE_MAX: f64 = 10.0;
+pub const ELEMENT_ROTATION_MIN: f64 = -180.0;
+pub const ELEMENT_ROTATION_MAX: f64 = 180.0;
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ImageMode {
@@ -91,6 +95,8 @@ pub struct KeyPosition {
pub width: f64,
#[serde(default = "default_key_height")]
pub height: f64,
+ #[serde(default = "default_element_rotation")]
+ pub rotation: f64,
/// 레이어 표시 여부 (true면 숨김)
#[serde(default)]
pub hidden: bool,
@@ -267,6 +273,7 @@ impl Default for KeyPosition {
dy: 0.0,
width: 60.0,
height: default_key_height(),
+ rotation: default_element_rotation(),
hidden: false,
active_image: None,
inactive_image: None,
@@ -411,6 +418,269 @@ pub struct KnobPosition {
pub position: KeyPosition,
}
+pub const SPRITE_TRANSFORM_OFFSET_MIN: f64 = -2_000.0;
+pub const SPRITE_TRANSFORM_OFFSET_MAX: f64 = 2_000.0;
+pub const SPRITE_TRANSFORM_ROTATION_MIN: f64 = -180.0;
+pub const SPRITE_TRANSFORM_ROTATION_MAX: f64 = 180.0;
+pub const SPRITE_TRANSFORM_SCALE_MIN: f64 = 0.1;
+pub const SPRITE_TRANSFORM_SCALE_MAX: f64 = 10.0;
+pub const SPRITE_TRANSITION_MS_MAX: u32 = 1_000;
+pub const SPRITE_PRESS_DURATION_MS_MIN: u32 = 1;
+pub const SPRITE_PRESS_DURATION_MS_MAX: u32 = 5_000;
+pub const SPRITE_IMAGE_DIMENSION_MIN: u32 = 1;
+pub const SPRITE_IMAGE_DIMENSION_MAX: u32 = 32_768;
+pub const MAX_SPRITE_POSES: usize = 64;
+pub const MAX_SPRITE_POSE_TRIGGERS: usize = 512;
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct SpriteAnchor {
+ pub x: f64,
+ pub y: f64,
+}
+
+impl Default for SpriteAnchor {
+ fn default() -> Self {
+ Self { x: 0.5, y: 0.5 }
+ }
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct SpriteTransform {
+ pub x: f64,
+ pub y: f64,
+ pub rotation: f64,
+ pub scale: f64,
+}
+
+impl Default for SpriteTransform {
+ fn default() -> Self {
+ Self {
+ x: 0.0,
+ y: 0.0,
+ rotation: 0.0,
+ scale: 1.0,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub struct SpriteImageMetrics {
+ pub source: String,
+ pub width: u32,
+ pub height: u32,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub struct SpriteReferenceNaturalSize {
+ pub source: Option,
+ pub width: u32,
+ pub height: u32,
+}
+
+pub(crate) fn is_renderable_image_ref(value: Option<&str>) -> bool {
+ value.is_some_and(|value| !value.trim().is_empty())
+}
+
+pub(crate) trait CoupledSpriteImageReference {
+ fn image_reference(&self) -> &Option;
+ fn image_reference_mut(&mut self) -> &mut Option;
+ fn coupled_source(&self) -> Option<&str>;
+ fn set_coupled_source(&mut self, source: Option<&str>);
+}
+
+pub(crate) fn rewrite_coupled_sprite_image_reference(
+ target: &mut T,
+ rewrite: impl FnOnce(&mut Option) -> R,
+) -> R
+where
+ T: CoupledSpriteImageReference,
+{
+ let original = target.image_reference().clone();
+ let source_was_coupled = original.as_deref().is_some_and(|image_ref| {
+ is_renderable_image_ref(Some(image_ref)) && target.coupled_source() == Some(image_ref)
+ });
+ let result = rewrite(target.image_reference_mut());
+
+ if source_was_coupled {
+ let rewritten = target
+ .image_reference()
+ .as_deref()
+ .filter(|image_ref| is_renderable_image_ref(Some(image_ref)))
+ .map(str::to_owned);
+ target.set_coupled_source(rewritten.as_deref());
+ }
+
+ result
+}
+
+#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub enum SpriteActivation {
+ #[default]
+ WhileHeld,
+ OnPress,
+}
+
+pub(crate) fn default_sprite_press_duration_ms() -> u32 {
+ 300
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct SpritePose {
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ pub pose_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub name: Option,
+ pub triggers: Vec,
+ pub transform: SpriteTransform,
+ #[serde(default)]
+ pub pivot: Option,
+ #[serde(default)]
+ pub image_override: Option,
+ #[serde(default)]
+ pub image_override_metrics: Option,
+}
+
+impl SpritePose {
+ pub fn normalize_triggers(&mut self) -> bool {
+ let original = self.triggers.clone();
+ self.triggers.sort_unstable();
+ self.triggers.dedup();
+ self.triggers != original
+ }
+}
+
+impl CoupledSpriteImageReference for SpritePose {
+ fn image_reference(&self) -> &Option {
+ &self.image_override
+ }
+
+ fn image_reference_mut(&mut self) -> &mut Option {
+ &mut self.image_override
+ }
+
+ fn coupled_source(&self) -> Option<&str> {
+ self.image_override_metrics
+ .as_ref()
+ .map(|metrics| metrics.source.as_str())
+ }
+
+ fn set_coupled_source(&mut self, source: Option<&str>) {
+ match source {
+ Some(source) => {
+ if let Some(metrics) = self.image_override_metrics.as_mut() {
+ metrics.source = source.to_string();
+ }
+ }
+ None => self.image_override_metrics = None,
+ }
+ }
+}
+
+pub(crate) fn default_sprite_transition_ms() -> u32 {
+ 0
+}
+
+pub(crate) fn default_sprite_transition_easing() -> String {
+ "cubic-bezier(0.4, 0, 0.2, 1)".to_string()
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct ReactiveSpritePosition {
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ pub id: String,
+ pub dx: f64,
+ pub dy: f64,
+ pub width: f64,
+ pub height: f64,
+ #[serde(default = "default_element_rotation")]
+ pub rotation: f64,
+ #[serde(default)]
+ pub hidden: bool,
+ #[serde(default)]
+ pub z_index: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub layer_name: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub group_id: Option,
+ #[serde(default)]
+ pub class_name: Option,
+ #[serde(default)]
+ pub use_inline_styles: Option,
+ #[serde(default)]
+ pub base_image: Option,
+ #[serde(default)]
+ pub reference_natural_size: Option,
+ pub pivot: SpriteAnchor,
+ pub idle_transform: SpriteTransform,
+ #[serde(default)]
+ pub poses: Vec,
+ #[serde(default)]
+ pub activation: SpriteActivation,
+ #[serde(default = "default_sprite_press_duration_ms")]
+ pub press_duration_ms: u32,
+ #[serde(default = "default_sprite_transition_ms")]
+ pub transition_ms: u32,
+ #[serde(default = "default_sprite_transition_easing")]
+ pub transition_easing: String,
+}
+
+impl Default for ReactiveSpritePosition {
+ fn default() -> Self {
+ Self {
+ id: String::new(),
+ dx: 0.0,
+ dy: 0.0,
+ width: 200.0,
+ height: 200.0,
+ rotation: default_element_rotation(),
+ hidden: false,
+ z_index: None,
+ layer_name: None,
+ group_id: None,
+ class_name: None,
+ use_inline_styles: None,
+ base_image: None,
+ reference_natural_size: None,
+ pivot: SpriteAnchor::default(),
+ idle_transform: SpriteTransform::default(),
+ poses: Vec::new(),
+ activation: SpriteActivation::default(),
+ press_duration_ms: default_sprite_press_duration_ms(),
+ transition_ms: default_sprite_transition_ms(),
+ transition_easing: default_sprite_transition_easing(),
+ }
+ }
+}
+
+impl CoupledSpriteImageReference for ReactiveSpritePosition {
+ fn image_reference(&self) -> &Option {
+ &self.base_image
+ }
+
+ fn image_reference_mut(&mut self) -> &mut Option {
+ &mut self.base_image
+ }
+
+ fn coupled_source(&self) -> Option<&str> {
+ self.reference_natural_size
+ .as_ref()
+ .and_then(|reference| reference.source.as_deref())
+ }
+
+ fn set_coupled_source(&mut self, source: Option<&str>) {
+ if let Some(reference) = self.reference_natural_size.as_mut() {
+ reference.source = source.map(str::to_owned);
+ }
+ }
+}
+
pub(super) fn default_true() -> bool {
true
}
diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs
index 30a27faaf..fc6676050 100644
--- a/src-tauri/src/models/mod.rs
+++ b/src-tauri/src/models/mod.rs
@@ -23,13 +23,26 @@ pub use counter::{
use counter::{default_counter_font_size, default_counter_font_weight, default_gap};
pub use editor::*;
use element_positions::default_true;
+pub(crate) use element_positions::{
+ default_sprite_press_duration_ms, default_sprite_transition_ms, is_renderable_image_ref,
+ rewrite_coupled_sprite_image_reference,
+};
+// 테스트 기대값 조립에서만 참조
+#[cfg(test)]
+pub(crate) use element_positions::default_sprite_transition_easing;
pub use element_positions::{
ElementShadowSpec, GraphPosition, GraphPositions, GraphStatType, GraphType, ImageMode,
ImageTransform, KeyPosition, KeyPositions, KnobPosition, KnobPositions, NoteAlignment,
- StatPosition, StatPositions, StatType, IMAGE_TRANSFORM_OFFSET_MAX, IMAGE_TRANSFORM_OFFSET_MIN,
- IMAGE_TRANSFORM_ROTATION_MAX, IMAGE_TRANSFORM_ROTATION_MIN, IMAGE_TRANSFORM_SCALE_MAX,
- IMAGE_TRANSFORM_SCALE_MIN, POSITION_COLLECTION_FIELDS, SHADOW_BLUR_MAX, SHADOW_BLUR_MIN,
- SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN,
+ ReactiveSpritePosition, SpriteActivation, SpriteAnchor, SpriteImageMetrics, SpritePose,
+ SpritePositions, SpriteReferenceNaturalSize, SpriteTransform, StatPosition, StatPositions,
+ StatType, ELEMENT_ROTATION_MAX, ELEMENT_ROTATION_MIN, IMAGE_TRANSFORM_OFFSET_MAX,
+ IMAGE_TRANSFORM_OFFSET_MIN, IMAGE_TRANSFORM_ROTATION_MAX, IMAGE_TRANSFORM_ROTATION_MIN,
+ IMAGE_TRANSFORM_SCALE_MAX, IMAGE_TRANSFORM_SCALE_MIN, MAX_SPRITE_POSES,
+ MAX_SPRITE_POSE_TRIGGERS, POSITION_COLLECTION_FIELDS, SHADOW_BLUR_MAX, SHADOW_BLUR_MIN,
+ SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, SPRITE_IMAGE_DIMENSION_MAX, SPRITE_IMAGE_DIMENSION_MIN,
+ SPRITE_PRESS_DURATION_MS_MAX, SPRITE_PRESS_DURATION_MS_MIN, SPRITE_TRANSFORM_OFFSET_MAX,
+ SPRITE_TRANSFORM_OFFSET_MIN, SPRITE_TRANSFORM_ROTATION_MAX, SPRITE_TRANSFORM_ROTATION_MIN,
+ SPRITE_TRANSFORM_SCALE_MAX, SPRITE_TRANSFORM_SCALE_MIN, SPRITE_TRANSITION_MS_MAX,
};
pub use gesture::*;
pub use key_slot::{
@@ -37,7 +50,7 @@ pub use key_slot::{
SlotMatch, MAX_SLOT_KEYS,
};
use note_settings::{
- default_key_height, default_key_note_color, default_key_note_opacity,
+ default_element_rotation, default_key_height, default_key_note_color, default_key_note_opacity,
default_note_auto_y_correction, default_note_border_opacity, default_note_effect_enabled,
default_note_glow_enabled, default_note_glow_opacity, default_note_glow_size,
};
@@ -702,6 +715,8 @@ pub struct AppStoreData {
#[serde(default)]
pub knob_positions: KnobPositions,
#[serde(default)]
+ pub sprite_positions: SpritePositions,
+ #[serde(default)]
pub layer_groups: LayerGroups,
#[serde(default)]
pub key_counters: KeyCounters,
@@ -732,6 +747,7 @@ pub struct AppStoreData {
/// 분리 패널 창 존재 여부 (재시작 복원용)
#[serde(default)]
pub panel_detached: bool,
+ pub overlay_last_content_left_offset: Option,
pub overlay_last_content_top_offset: Option,
#[serde(default)]
pub overlay_bounds_are_logical: bool,
@@ -791,6 +807,7 @@ impl Default for AppStoreData {
stat_positions: StatPositions::new(),
graph_positions: GraphPositions::new(),
knob_positions: KnobPositions::new(),
+ sprite_positions: SpritePositions::new(),
layer_groups: LayerGroups::new(),
key_counters: KeyCounters::new(),
background_color: "transparent".to_string(),
@@ -807,6 +824,7 @@ impl Default for AppStoreData {
overlay_bounds: None,
panel_bounds: None,
panel_detached: false,
+ overlay_last_content_left_offset: None,
overlay_last_content_top_offset: None,
overlay_bounds_are_logical: false,
key_counter_enabled: false,
diff --git a/src-tauri/src/models/note_settings.rs b/src-tauri/src/models/note_settings.rs
index 46329f117..ae91ac2a7 100644
--- a/src-tauri/src/models/note_settings.rs
+++ b/src-tauri/src/models/note_settings.rs
@@ -10,6 +10,9 @@ pub(super) fn default_note_effect_enabled() -> bool {
pub(super) fn default_key_height() -> f64 {
60.0
}
+pub(super) fn default_element_rotation() -> f64 {
+ 0.0
+}
pub(super) fn default_key_note_color() -> NoteColor {
NoteColor::Solid("#FFFFFF".to_string())
}
diff --git a/src-tauri/src/models/tests.rs b/src-tauri/src/models/tests.rs
index 73d043d15..568005a6a 100644
--- a/src-tauri/src/models/tests.rs
+++ b/src-tauri/src/models/tests.rs
@@ -1,10 +1,12 @@
use super::{
compact_canonical_rgba, key_mappings_contain_multi, normalize_key_mappings, normalize_key_slot,
- note_border_representative_hex, scrub_removed_text_outline_fields, AppStoreData, FadePosition,
- GradientSpec, GraphPosition, GraphStatType, GraphType, ImageTransform, KeyCounterAlign,
- KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, KeyCounterSettings, KeyMappings,
- KeyPosition, KeySlot, KnobPosition, NoteAlignment, NoteColor, NoteSettings, SlotMatch,
- StatPosition, StatType, MAX_SLOT_KEYS, POSITION_COLLECTION_FIELDS,
+ note_border_representative_hex, rewrite_coupled_sprite_image_reference,
+ scrub_removed_text_outline_fields, AppStoreData, FadePosition, GradientSpec, GraphPosition,
+ GraphStatType, GraphType, ImageTransform, KeyCounterAlign, KeyCounterAlignMode,
+ KeyCounterColor, KeyCounterPlacement, KeyCounterSettings, KeyMappings, KeyPosition, KeySlot,
+ KnobPosition, NoteAlignment, NoteColor, NoteSettings, ReactiveSpritePosition, SlotMatch,
+ SpriteAnchor, SpritePose, SpriteReferenceNaturalSize, SpriteTransform, StatPosition, StatType,
+ MAX_SLOT_KEYS, POSITION_COLLECTION_FIELDS,
};
use serde::Deserialize;
@@ -23,6 +25,128 @@ struct ValidNoteBorderStopColor {
representative: String,
}
+#[test]
+fn element_rotation_constants_and_default_match_shared_fixture() {
+ #[derive(Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct Range {
+ min: f64,
+ max: f64,
+ }
+ #[derive(Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct Fixture {
+ rotation: Range,
+ default: f64,
+ }
+ let fixture: Fixture = serde_json::from_str(include_str!(
+ "../../../tests/fixtures/element-rotation-parity.json"
+ ))
+ .unwrap();
+ assert_eq!(super::ELEMENT_ROTATION_MIN, fixture.rotation.min);
+ assert_eq!(super::ELEMENT_ROTATION_MAX, fixture.rotation.max);
+ assert_eq!(super::default_element_rotation(), fixture.default);
+ assert_eq!(KeyPosition::default().rotation, fixture.default);
+ assert_eq!(ReactiveSpritePosition::default().rotation, fixture.default);
+}
+
+#[test]
+fn rotated_position_coordinates_survive_exact_json_round_trip() {
+ fn check(
+ position: T,
+ field: &str,
+ decimal: &str,
+ ) {
+ let expected: f64 = decimal.parse().unwrap();
+ let mut payload = serde_json::to_value(position).unwrap();
+ payload[field] = serde_json::json!(expected);
+ let wire = serde_json::to_string(&payload).unwrap();
+ let assert_exact = |position: &T| {
+ let encoded = serde_json::to_value(position).unwrap();
+ assert_eq!(
+ encoded[field].as_f64().unwrap().to_bits(),
+ expected.to_bits()
+ );
+ };
+
+ let direct: T = serde_json::from_str(&wire).unwrap();
+ assert_exact(&direct);
+ let ipc_payload: serde_json::Value = serde_json::from_str(&wire).unwrap();
+ let decoded: T = serde_json::from_value(ipc_payload).unwrap();
+ assert_exact(&decoded);
+ let stored = serde_json::to_string(&decoded).unwrap();
+ let restored: T = serde_json::from_str(&stored).unwrap();
+ assert_exact(&restored);
+ }
+
+ check(KeyPosition::default(), "dy", "209.77425768631718");
+ check(
+ ReactiveSpritePosition::default(),
+ "dx",
+ "114.55722993194729",
+ );
+}
+
+#[test]
+fn element_rotation_defaults_and_round_trips_flattened_positions() {
+ fn check(mut value: serde_json::Value) {
+ value.as_object_mut().unwrap().remove("rotation");
+ let legacy: T = serde_json::from_value(value.clone()).unwrap();
+ let serialized = serde_json::to_value(legacy).unwrap();
+ assert_eq!(serialized["rotation"], 0.0);
+ assert!(serialized.get("position").is_none());
+ for rotation in [-180.0, -45.5, 0.0, 45.5, 180.0] {
+ value["rotation"] = serde_json::json!(rotation);
+ let decoded: T = serde_json::from_value(value.clone()).unwrap();
+ let wire = serde_json::to_value(decoded).unwrap();
+ assert_eq!(wire["rotation"], rotation);
+ let restored: T = serde_json::from_value(wire.clone()).unwrap();
+ assert_eq!(serde_json::to_value(restored).unwrap(), wire);
+ }
+ for invalid in [
+ serde_json::Value::Null,
+ serde_json::json!("45"),
+ serde_json::json!(true),
+ ] {
+ value["rotation"] = invalid;
+ assert!(serde_json::from_value::(value.clone()).is_err());
+ }
+ }
+
+ let position = KeyPosition::default();
+ check::(serde_json::to_value(&position).unwrap());
+ check::(
+ serde_json::to_value(ReactiveSpritePosition::default()).unwrap(),
+ );
+ check::(
+ serde_json::to_value(StatPosition {
+ stat_type: StatType::Kps,
+ position: position.clone(),
+ })
+ .unwrap(),
+ );
+ check::(
+ serde_json::to_value(GraphPosition {
+ stat_type: GraphStatType::Kps,
+ graph_type: GraphType::Line,
+ graph_speed: 100,
+ graph_color: "#123456".to_string(),
+ show_avg_line: true,
+ position: position.clone(),
+ })
+ .unwrap(),
+ );
+ check::(
+ serde_json::to_value(KnobPosition {
+ axis_id: "axis".to_string(),
+ sensitivity: 1.0,
+ reverse: false,
+ position,
+ })
+ .unwrap(),
+ );
+}
+
#[test]
fn app_store_data_defaults_missing_bar_count_to_four() {
let mut value = serde_json::to_value(AppStoreData::default()).unwrap();
@@ -44,6 +168,129 @@ fn legacy_string_key_mappings_round_trip_without_loss() {
assert_eq!(serde_json::to_value(mappings).unwrap(), raw);
}
+#[test]
+fn sprite_pose_optional_fields_round_trip_with_canonical_pivot_null() {
+ let pose = SpritePose {
+ name: Some("왼팔".to_string()),
+ ..SpritePose::default()
+ };
+
+ let serialized = serde_json::to_value(&pose).unwrap();
+ assert_eq!(serialized["name"], "왼팔");
+ assert!(serialized.get("matchMode").is_none());
+
+ let restored: SpritePose = serde_json::from_value(serialized).unwrap();
+ assert_eq!(restored.name.as_deref(), Some("왼팔"));
+
+ let unnamed = serde_json::to_value(SpritePose::default()).unwrap();
+ assert!(unnamed.get("name").is_none());
+ assert_eq!(unnamed["pivot"], serde_json::Value::Null);
+}
+
+#[test]
+fn sprite_pose_trigger_normalization_reports_changes_once() {
+ let mut pose = SpritePose {
+ triggers: vec!["b".to_string(), "a".to_string(), "b".to_string()],
+ ..SpritePose::default()
+ };
+
+ assert!(pose.normalize_triggers());
+ assert_eq!(pose.triggers, ["a", "b"]);
+ assert!(!pose.normalize_triggers());
+}
+
+#[test]
+fn coupled_image_rewrite_keeps_null_reference_source_without_base_image() {
+ let mut sprite = ReactiveSpritePosition {
+ reference_natural_size: Some(SpriteReferenceNaturalSize {
+ source: None,
+ width: 100,
+ height: 100,
+ }),
+ ..ReactiveSpritePosition::default()
+ };
+
+ rewrite_coupled_sprite_image_reference(&mut sprite, |image_ref| {
+ assert_eq!(image_ref, &None);
+ });
+
+ assert_eq!(sprite.reference_natural_size.unwrap().source, None);
+}
+
+#[test]
+fn sprite_fields_follow_single_model_wire_contract() {
+ let mut raw = serde_json::to_value(ReactiveSpritePosition::default()).unwrap();
+ let sprite = raw.as_object_mut().unwrap();
+ sprite.insert("imageFit".to_string(), serde_json::json!("contain"));
+ sprite.remove("imagePlacement");
+ sprite.remove("referenceNaturalSize");
+ sprite.insert(
+ "imageRect".to_string(),
+ serde_json::json!({ "x": 10, "y": 20, "width": 40, "height": 50 }),
+ );
+ sprite.insert(
+ "poses".to_string(),
+ serde_json::json!([{
+ "poseId": "pose-a",
+ "triggers": [],
+ "transform": SpriteTransform::default(),
+ "imageOverride": null,
+ "contactPoint": { "x": 0.5, "y": 1.0 },
+ "imagePivot": { "x": 0.25, "y": 0.75 }
+ }]),
+ );
+
+ let defaulted: ReactiveSpritePosition = serde_json::from_value(raw).unwrap();
+ assert_eq!((defaulted.width, defaulted.height), (200.0, 200.0));
+ assert_eq!(defaulted.pivot, SpriteAnchor { x: 0.5, y: 0.5 });
+ assert_eq!(defaulted.reference_natural_size, None);
+ assert_eq!(defaulted.poses[0].image_override_metrics, None);
+
+ let serialized = serde_json::to_value(&defaulted).unwrap();
+ for field in ["imageFit", "imagePlacement", "imageRect"] {
+ assert!(serialized.get(field).is_none());
+ }
+ for field in ["contactPoint", "imagePivot"] {
+ assert!(serialized["poses"][0].get(field).is_none());
+ }
+ assert!(serialized["referenceNaturalSize"].is_null());
+ assert!(serialized["poses"][0]["imageOverrideMetrics"].is_null());
+ let null_round_trip: ReactiveSpritePosition =
+ serde_json::from_value(serialized.clone()).unwrap();
+ assert_eq!(null_round_trip.reference_natural_size, None);
+ assert_eq!(null_round_trip.poses[0].image_override_metrics, None);
+
+ let mut valued_raw = serde_json::to_value(ReactiveSpritePosition::default()).unwrap();
+ let valued_object = valued_raw.as_object_mut().unwrap();
+ valued_object.insert(
+ "referenceNaturalSize".to_string(),
+ serde_json::json!({ "source": null, "width": 1920, "height": 1080 }),
+ );
+ valued_object.insert(
+ "poses".to_string(),
+ serde_json::json!([{
+ "poseId": "pose-a",
+ "triggers": [],
+ "transform": SpriteTransform::default(),
+ "imageOverride": "/images/pose.png",
+ "imageOverrideMetrics": {
+ "source": "/images/pose.png",
+ "width": 640,
+ "height": 480
+ }
+ }]),
+ );
+ let valued: ReactiveSpritePosition = serde_json::from_value(valued_raw).unwrap();
+ assert_eq!(valued.reference_natural_size.unwrap().source, None);
+ assert_eq!(
+ valued.poses[0]
+ .image_override_metrics
+ .as_ref()
+ .map(|metrics| metrics.source.as_str()),
+ Some("/images/pose.png")
+ );
+}
+
#[test]
fn legacy_700_weights_migrate_to_400_with_bold_modifier() {
let mut raw = serde_json::to_value(KeyPosition::default()).unwrap();
@@ -177,7 +424,7 @@ fn position_wrappers_preserve_legacy_missing_field_defaults_and_round_trip() {
fn position_serialization_field_order_and_related_defaults_are_stable() {
let position = KeyPosition::default();
assert!(serde_json::to_string(&position).unwrap().starts_with(
- r#"{"dx":0.0,"dy":0.0,"width":60.0,"height":60.0,"hidden":false,"activeImage":null"#
+ r#"{"dx":0.0,"dy":0.0,"width":60.0,"height":60.0,"rotation":0.0,"hidden":false,"activeImage":null"#
));
assert!(serde_json::to_string(&StatPosition {
stat_type: StatType::Kps,
diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs
index e1b45f16a..f6cf5e9cd 100644
--- a/src-tauri/src/services/obs_bridge.rs
+++ b/src-tauri/src/services/obs_bridge.rs
@@ -71,6 +71,7 @@ const FORWARDED_EVENTS: &[&str] = &[
"statPositions:changed",
"graphPositions:changed",
"knobPositions:changed",
+ "spritePositions:changed",
"layerGroups:changed",
"overlay:visibility",
"overlay:lock",
diff --git a/src-tauri/src/services/obs_bridge/media.rs b/src-tauri/src/services/obs_bridge/media.rs
index a826b2071..b1fe42035 100644
--- a/src-tauri/src/services/obs_bridge/media.rs
+++ b/src-tauri/src/services/obs_bridge/media.rs
@@ -5,6 +5,7 @@ use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;
use super::transport::write_empty_http_response;
+use crate::state::assets::image_asset::SUPPORTED_IMAGE_EXTENSIONS;
pub(super) async fn handle_media_request(
stream: &mut TcpStream,
@@ -101,22 +102,7 @@ pub(super) async fn handle_media_request(
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
- if !matches!(
- ext.as_str(),
- "png"
- | "jpg"
- | "jpeg"
- | "gif"
- | "webp"
- | "svg"
- | "mp4"
- | "webm"
- | "ogg"
- | "woff"
- | "woff2"
- | "ttf"
- | "otf"
- ) {
+ if !is_servable_media_extension(&ext) {
let _ = stream
.write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
.await;
@@ -144,6 +130,15 @@ pub(super) async fn handle_media_request(
}
}
+// 이미지는 선택창 목록 그대로, 나머지는 영상·폰트만
+fn is_servable_media_extension(ext: &str) -> bool {
+ SUPPORTED_IMAGE_EXTENSIONS.contains(&ext)
+ || matches!(
+ ext,
+ "mp4" | "webm" | "ogg" | "woff" | "woff2" | "ttf" | "otf"
+ )
+}
+
/// 파일 확장자로 MIME 타입 추정
pub(super) fn guess_mime(path: &str) -> &'static str {
match path
@@ -162,6 +157,9 @@ pub(super) fn guess_mime(path: &str) -> &'static str {
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
+ "bmp" => "image/bmp",
+ "ico" => "image/x-icon",
+ "avif" => "image/avif",
"mp4" => "video/mp4",
"webm" => "video/webm",
"ogg" => "video/ogg",
diff --git a/src-tauri/src/services/obs_bridge/media/tests.rs b/src-tauri/src/services/obs_bridge/media/tests.rs
index 932e79be1..63c9049da 100644
--- a/src-tauri/src/services/obs_bridge/media/tests.rs
+++ b/src-tauri/src/services/obs_bridge/media/tests.rs
@@ -6,28 +6,29 @@ use tokio::net::{TcpListener, TcpStream};
use super::*;
+// 선택창이 받는 이미지는 전부 OBS에서도 서빙되고 image/* MIME으로 나가야 한다
#[test]
-fn percent_decoding_preserves_malformed_sequences_and_unicode() {
- for (input, expected) in [
- ("%가", "%가"),
- ("%a가", "%a가"),
- ("%😀", "%😀"),
- ("%", "%"),
- ("%2", "%2"),
- ("%GG", "%GG"),
- ("한글%2f이미지%2F", "한글/이미지/"),
- ("%EA%B0%80", "가"),
- ("%FF", "�"),
- ] {
- assert_eq!(percent_decode(input), expected, "input: {input}");
+fn media_whitelist_serves_every_picker_image_extension() {
+ for ext in SUPPORTED_IMAGE_EXTENSIONS {
+ assert!(
+ is_servable_media_extension(ext),
+ "{ext} 이미지가 OBS 서빙에서 빠짐"
+ );
+ let mime = guess_mime(&format!("/app/images/sample.{ext}"));
+ assert!(
+ mime.starts_with("image/"),
+ "{ext} MIME이 image/*가 아님: {mime}"
+ );
}
+ assert_eq!(guess_mime("/app/images/a.BMP"), "image/bmp");
+ assert_eq!(guess_mime("/app/images/a.ico"), "image/x-icon");
+ assert_eq!(guess_mime("/app/images/a.avif"), "image/avif");
}
-#[tokio::test]
-async fn authenticated_malformed_media_path_returns_bad_request() {
- for path in ["%가", "%a가", "%😀", "%", "%2", "%GG"] {
- let response = media_response("secret", &format!("{path}?token=secret"), None).await;
- assert_eq!(response, empty_response("400 Bad Request"), "path: {path}");
+#[test]
+fn media_whitelist_still_rejects_non_media_files() {
+ for ext in ["json", "html", "js", "exe", "css", ""] {
+ assert!(!is_servable_media_extension(ext), "{ext} 서빙 허용됨");
}
}
@@ -276,3 +277,28 @@ async fn canonical_containment_follows_symlinks_before_authorization() {
assert!(response.starts_with(b"HTTP/1.1 200 OK"));
assert!(response.ends_with(b"inside"));
}
+
+#[test]
+fn percent_decoding_preserves_malformed_sequences_and_unicode() {
+ for (input, expected) in [
+ ("%가", "%가"),
+ ("%a가", "%a가"),
+ ("%😀", "%😀"),
+ ("%", "%"),
+ ("%2", "%2"),
+ ("%GG", "%GG"),
+ ("한글%2f이미지%2F", "한글/이미지/"),
+ ("%EA%B0%80", "가"),
+ ("%FF", "�"),
+ ] {
+ assert_eq!(percent_decode(input), expected, "input: {input}");
+ }
+}
+
+#[tokio::test]
+async fn authenticated_malformed_media_path_returns_bad_request() {
+ for path in ["%가", "%a가", "%😀", "%", "%2", "%GG"] {
+ let response = media_response("secret", &format!("{path}?token=secret"), None).await;
+ assert_eq!(response, empty_response("400 Bad Request"), "path: {path}");
+ }
+}
diff --git a/src-tauri/src/services/obs_bridge/tests.rs b/src-tauri/src/services/obs_bridge/tests.rs
index 5698b7d21..cf42aaa2c 100644
--- a/src-tauri/src/services/obs_bridge/tests.rs
+++ b/src-tauri/src/services/obs_bridge/tests.rs
@@ -252,12 +252,13 @@ fn bridge_messages_targeting_main_are_not_forwarded_to_obs() {
#[test]
fn public_overlay_events_are_forwarded_to_obs_clients() {
- assert_eq!(FORWARDED_EVENTS.len(), 32);
+ assert_eq!(FORWARDED_EVENTS.len(), 33);
for event in [
"customTabs:changed",
"overlay:resized",
"counterAnimation:changed",
"preset:snapshot",
+ "spritePositions:changed",
] {
assert!(FORWARDED_EVENTS.contains(&event), "missing event: {event}");
}
diff --git a/src-tauri/src/services/overlay_hit.rs b/src-tauri/src/services/overlay_hit.rs
index 82f70a893..9bd7d9719 100644
--- a/src-tauri/src/services/overlay_hit.rs
+++ b/src-tauri/src/services/overlay_hit.rs
@@ -346,6 +346,12 @@ impl OverlayHitService {
.context("failed to dispatch overlay hit reconciliation")
}
+ #[cfg(target_os = "macos")]
+ pub(crate) fn reconcile_after_parent_order(&self, app: &AppHandle) -> Result<()> {
+ platform::request_reorder(&mut self.inner.native.lock());
+ self.reconcile(app)
+ }
+
fn reconcile_on_main(&self, app: &AppHandle) -> Result<()> {
let overlay = app.get_webview_window(OVERLAY_LABEL);
let parent = platform::parent_identity(overlay.as_ref())?;
@@ -359,6 +365,8 @@ impl OverlayHitService {
(desired.clone(), parent_changed)
};
let mut native = self.inner.native.lock();
+ #[cfg(target_os = "macos")]
+ platform::set_owner(&mut native, Arc::downgrade(&self.inner));
let status = platform::reconcile(app, overlay.as_ref(), &desired, &mut native)?;
drop(native);
diff --git a/src-tauri/src/services/overlay_hit/platform/macos.rs b/src-tauri/src/services/overlay_hit/platform/macos.rs
index 698ac8078..9b7eaca22 100644
--- a/src-tauri/src/services/overlay_hit/platform/macos.rs
+++ b/src-tauri/src/services/overlay_hit/platform/macos.rs
@@ -1,13 +1,17 @@
use std::{
os::raw::c_void,
panic::{catch_unwind, AssertUnwindSafe},
+ sync::{
+ atomic::{AtomicBool, Ordering},
+ Weak,
+ },
};
use anyhow::{anyhow, Context, Result};
use cocoa::{
appkit::{NSBackingStoreBuffered, NSEvent, NSEventType},
base::{id, nil, BOOL, NO, YES},
- foundation::{NSInteger, NSPoint, NSRect, NSSize},
+ foundation::{NSInteger, NSPoint, NSRect, NSSize, NSString},
};
use objc::{
class,
@@ -21,22 +25,43 @@ use tauri::{AppHandle, Emitter, WebviewWindow};
use super::{
clip_hit_rect_to_bounds, hit_region_status, HitRegionStatus, OverlayHitContextMenuPayload,
- OverlayHitDesiredState, OverlayHitRect, OVERLAY_LABEL,
+ OverlayHitDesiredState, OverlayHitRect, OverlayHitService, OverlayHitServiceInner,
+ OVERLAY_LABEL,
};
const HIT_CONTEXT_IVAR: &str = "dmNoteOverlayHitContext";
+const PARENT_OBSERVER_IVAR: &str = "dmNoteOverlayParentObserverContext";
const NS_NONACTIVATING_PANEL_MASK: u64 = 1 << 7;
const NS_WINDOW_ABOVE: i64 = 1;
+const NS_WINDOW_ANIMATION_NONE: NSInteger = 2;
#[derive(Default)]
pub(super) struct NativeState {
panels: Vec,
+ owner: Weak,
+ observer: Option,
+ needs_reorder: bool,
}
struct HitPanel {
panel: usize,
view: usize,
context: Box,
+ frame: NSRect,
+ level: Option,
+ collection_behavior: Option,
+}
+
+struct ParentObserver {
+ object: usize,
+ parent: usize,
+ _context: Box,
+}
+
+struct ParentObserverContext {
+ app: AppHandle,
+ owner: Weak,
+ queued: AtomicBool,
}
struct HitContext {
@@ -100,6 +125,135 @@ static HIT_VIEW_CLASS: Lazy = Lazy::new(|| unsafe {
ObjectiveCClass(declaration.register())
});
+static PARENT_OBSERVER_CLASS: Lazy = Lazy::new(|| unsafe {
+ if let Some(class) = Class::get("DmNoteOverlayParentObserver") {
+ return ObjectiveCClass(class);
+ }
+ let mut declaration = ClassDecl::new("DmNoteOverlayParentObserver", class!(NSObject))
+ .expect("overlay parent observer class");
+ declaration.add_method(
+ sel!(parentStateChanged:),
+ parent_state_changed as extern "C" fn(&Object, Sel, id),
+ );
+ declaration.add_method(
+ sel!(reconcileParent:),
+ reconcile_parent as extern "C" fn(&Object, Sel, id),
+ );
+ declaration.add_ivar::<*mut c_void>(PARENT_OBSERVER_IVAR);
+ ObjectiveCClass(declaration.register())
+});
+
+extern "C" fn parent_state_changed(this: &Object, _selector: Sel, _notification: id) {
+ let _ = catch_unwind(AssertUnwindSafe(|| unsafe {
+ let pointer: *mut c_void = *this.get_ivar(PARENT_OBSERVER_IVAR);
+ let Some(context) = pointer.cast::().as_ref() else {
+ return;
+ };
+ if context.queued.swap(true, Ordering::AcqRel) {
+ return;
+ }
+ // AppKit 알림은 창 정산 중에도 발생하므로 다음 공통 실행 루프에서 처리
+ let _: () = msg_send![this,
+ performSelectorOnMainThread: sel!(reconcileParent:)
+ withObject: nil
+ waitUntilDone: NO
+ ];
+ }));
+}
+
+extern "C" fn reconcile_parent(this: &Object, _selector: Sel, _argument: id) {
+ let _ = catch_unwind(AssertUnwindSafe(|| {
+ let (owner, app) = unsafe {
+ let pointer: *mut c_void = *this.get_ivar(PARENT_OBSERVER_IVAR);
+ let Some(context) = pointer.cast::().as_ref() else {
+ return;
+ };
+ context.queued.store(false, Ordering::Release);
+ (context.owner.upgrade(), context.app.clone())
+ };
+ if let Some(inner) = owner {
+ if let Err(error) = (OverlayHitService { inner }).reconcile_after_parent_order(&app) {
+ log::warn!("failed to follow macOS overlay parent state: {error:#}");
+ }
+ }
+ }));
+}
+
+impl ParentObserver {
+ fn new(app: &AppHandle, parent: id, owner: Weak) -> Result {
+ let mut context = Box::new(ParentObserverContext {
+ app: app.clone(),
+ owner,
+ queued: AtomicBool::new(false),
+ });
+ unsafe {
+ let object: id = msg_send![PARENT_OBSERVER_CLASS.0, new];
+ if object.is_null() {
+ return Err(anyhow!("failed to create overlay parent observer"));
+ }
+ (*object).set_ivar(
+ PARENT_OBSERVER_IVAR,
+ (&mut *context as *mut ParentObserverContext).cast::(),
+ );
+ let center: id = msg_send![class!(NSNotificationCenter), defaultCenter];
+ add_observer(
+ center,
+ object,
+ "NSWindowDidChangeOcclusionStateNotification",
+ parent,
+ );
+ let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace];
+ let center: id = msg_send![workspace, notificationCenter];
+ for name in [
+ "NSWorkspaceActiveSpaceDidChangeNotification",
+ "NSWorkspaceDidActivateApplicationNotification",
+ ] {
+ add_observer(center, object, name, nil);
+ }
+ Ok(Self {
+ object: object as usize,
+ parent: parent as usize,
+ _context: context,
+ })
+ }
+ }
+}
+
+impl Drop for ParentObserver {
+ fn drop(&mut self) {
+ unsafe {
+ let object = self.object as id;
+ let center: id = msg_send![class!(NSNotificationCenter), defaultCenter];
+ let _: () = msg_send![center, removeObserver: object];
+ let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace];
+ let center: id = msg_send![workspace, notificationCenter];
+ let _: () = msg_send![center, removeObserver: object];
+ // 이미 예약된 선택자는 객체를 유지하므로 컨텍스트만 먼저 해제
+ (*object).set_ivar(PARENT_OBSERVER_IVAR, std::ptr::null_mut::());
+ let _: () = msg_send![object, release];
+ }
+ }
+}
+
+unsafe fn add_observer(center: id, observer: id, name: &str, object: id) {
+ let name = NSString::alloc(nil).init_str(name);
+ let _: () = msg_send![center,
+ addObserver: observer
+ selector: sel!(parentStateChanged:)
+ name: name
+ object: object
+ ];
+ let _: () = msg_send![name, release];
+}
+
+pub(super) fn set_owner(native: &mut NativeState, owner: Weak) {
+ native.owner = owner;
+}
+
+pub(super) fn request_reorder(native: &mut NativeState) {
+ native.needs_reorder = true;
+}
+
extern "C" fn never_becomes_key(_this: &Object, _selector: Sel) -> BOOL {
NO
}
@@ -223,6 +377,7 @@ pub(super) fn reconcile(
native: &mut NativeState,
) -> Result {
let Some(overlay) = overlay else {
+ native.observer = None;
hide_panels(native);
return Ok(HitRegionStatus::Applied);
};
@@ -230,11 +385,17 @@ pub(super) fn reconcile(
.ns_window()
.context("failed to get overlay NSWindow")? as id;
if parent.is_null() {
+ native.observer = None;
hide_panels(native);
return Ok(HitRegionStatus::Applied);
}
+ if native.observer.as_ref().map(|observer| observer.parent) != Some(parent as usize) {
+ native.observer = Some(ParentObserver::new(app, parent, native.owner.clone())?);
+ native.needs_reorder = true;
+ }
let parent_visible: BOOL = unsafe { msg_send![parent, isVisible] };
+ let parent_on_space: BOOL = unsafe { msg_send![parent, isOnActiveSpace] };
// 프론트가 "키 0개"를 확정 보고했으면 콘텐츠 전체를 잡는다 - 모든 키를 숨기거나
// 커스텀 CSS로 지웠을 때 창을 옮길 수도 우클릭할 수도 없게 되는 것을 막는다.
// 첫 측정 전(last_revision == None)에는 그대로 클릭 통과
@@ -258,8 +419,9 @@ pub(super) fn reconcile(
.filter_map(|rect| clip_hit_rect_to_bounds(*rect, content_width, content_height))
.collect::>()
};
+ let parent_available = parent_visible != NO && parent_on_space != NO;
let active =
- desired.visible && !desired.locked && !clipped_rects.is_empty() && parent_visible != NO;
+ desired.visible && !desired.locked && !clipped_rects.is_empty() && parent_available;
if !active {
hide_panels(native);
if clipped_rects.is_empty() {
@@ -274,6 +436,14 @@ pub(super) fn reconcile(
return Ok(HitRegionStatus::Applied);
}
resize_panel_pool(app, parent, &clipped_rects, native)?;
+ let (parent_level, parent_behavior, parent_number): (NSInteger, u64, NSInteger) = unsafe {
+ (
+ msg_send![parent, level],
+ msg_send![parent, collectionBehavior],
+ msg_send![parent, windowNumber],
+ )
+ };
+ let collection_behavior = hit_panel_collection_behavior(parent_behavior);
for (panel, rect) in native.panels.iter_mut().zip(&clipped_rects) {
panel.context.parent = parent as usize;
@@ -282,25 +452,49 @@ pub(super) fn reconcile(
unsafe {
let panel_id = panel.panel as id;
let view_id = panel.view as id;
- let current_parent: id = msg_send![panel_id, parentWindow];
- if current_parent != parent {
- if !current_parent.is_null() {
- let _: () = msg_send![current_parent, removeChildWindow: panel_id];
- }
- let _: () = msg_send![parent, addChildWindow: panel_id ordered: NS_WINDOW_ABOVE];
+ let reorder = native.needs_reorder
+ || !panel.context.active
+ || panel.level != Some(parent_level)
+ || panel.collection_behavior != Some(collection_behavior);
+ if panel.level != Some(parent_level) {
+ let _: () = msg_send![panel_id, setLevel: parent_level];
+ panel.level = Some(parent_level);
+ }
+ if panel.collection_behavior != Some(collection_behavior) {
+ let _: () = msg_send![panel_id, setCollectionBehavior: collection_behavior];
+ panel.collection_behavior = Some(collection_behavior);
+ }
+ if panel.frame.origin.x != frame.origin.x
+ || panel.frame.origin.y != frame.origin.y
+ || panel.frame.size.width != frame.size.width
+ || panel.frame.size.height != frame.size.height
+ || !panel.context.active
+ {
+ let _: () = msg_send![panel_id, setFrame: frame display: NO];
+ let _: () = msg_send![panel_id, invalidateCursorRectsForView: view_id];
+ panel.frame = frame;
+ }
+ if !panel.context.active {
+ let _: () = msg_send![panel_id, setIgnoresMouseEvents: NO];
}
- let parent_level: NSInteger = msg_send![parent, level];
- let _: () = msg_send![panel_id, setLevel: parent_level];
- let _: () = msg_send![panel_id, setFrame: frame display: NO];
- let _: () = msg_send![panel_id, setIgnoresMouseEvents: NO];
- let _: () = msg_send![panel_id, invalidateCursorRectsForView: view_id];
panel.context.active = true;
- let _: () = msg_send![panel_id, orderFront: nil];
+ if reorder {
+ // 자식 창 그룹과 중복 정렬은 회전 띠의 대량 정리 비용을 키움
+ let _: () =
+ msg_send![panel_id, orderWindow: NS_WINDOW_ABOVE relativeTo: parent_number];
+ }
}
}
+ native.needs_reorder = false;
Ok(HitRegionStatus::Applied)
}
+fn hit_panel_collection_behavior(parent_behavior: u64) -> u64 {
+ let managed_stationary_cycle = (1 << 2) | (1 << 4) | (1 << 5);
+ let transient_ignores_cycle = (1 << 3) | (1 << 6);
+ (parent_behavior & !managed_stationary_cycle) | transient_ignores_cycle
+}
+
fn content_size(parent: id) -> Result<(f64, f64)> {
unsafe {
let content_view: id = msg_send![parent, contentView];
@@ -364,18 +558,20 @@ fn create_panel(app: &AppHandle, parent: id, rect: OverlayHitRect) -> Result Result {
fn hide_panels(native: &mut NativeState) {
for panel in &mut native.panels {
+ if !panel.context.active {
+ continue;
+ }
panel.context.parent = 0;
panel.context.active = false;
unsafe {
@@ -414,10 +613,6 @@ fn destroy_panel(panel: HitPanel) {
let panel_id = panel.panel as id;
let view_id = panel.view as id;
(*view_id).set_ivar(HIT_CONTEXT_IVAR, std::ptr::null_mut::());
- let parent: id = msg_send![panel_id, parentWindow];
- if !parent.is_null() {
- let _: () = msg_send![parent, removeChildWindow: panel_id];
- }
let _: () = msg_send![panel_id, orderOut: nil];
let _: () = msg_send![panel_id, close];
let _: () = msg_send![panel_id, release];
@@ -428,4 +623,26 @@ fn destroy_panel(panel: HitPanel) {
pub(super) fn register_classes_for_test() {
Lazy::force(&HIT_PANEL_CLASS);
Lazy::force(&HIT_VIEW_CLASS);
+ Lazy::force(&PARENT_OBSERVER_CLASS);
+}
+
+#[cfg(test)]
+mod tests {
+ use super::hit_panel_collection_behavior;
+
+ #[test]
+ fn hit_panels_follow_parent_spaces_without_joining_window_cycles() {
+ let spaces_and_fullscreen = (1 << 0) | (1 << 8);
+ let managed_stationary_cycle = (1 << 2) | (1 << 4) | (1 << 5);
+ let transient_ignores_cycle = (1 << 3) | (1 << 6);
+ for parent in [
+ spaces_and_fullscreen,
+ spaces_and_fullscreen | managed_stationary_cycle,
+ ] {
+ let behavior = hit_panel_collection_behavior(parent);
+ assert_eq!(behavior & spaces_and_fullscreen, spaces_and_fullscreen);
+ assert_eq!(behavior & managed_stationary_cycle, 0);
+ assert_eq!(behavior & transient_ignores_cycle, transient_ignores_cycle);
+ }
+ }
}
diff --git a/src-tauri/src/services/preview_broker.rs b/src-tauri/src/services/preview_broker.rs
index 057e64197..762713aef 100644
--- a/src-tauri/src/services/preview_broker.rs
+++ b/src-tauri/src/services/preview_broker.rs
@@ -24,6 +24,7 @@ const KEY_POSITION_PATCH_FIELDS: &[&str] = &[
"dy",
"width",
"height",
+ "rotation",
"hidden",
"activeImage",
"inactiveImage",
@@ -89,6 +90,22 @@ const KEY_POSITION_PATCH_FIELDS: &[&str] = &[
"fontStrikethrough",
];
+const SPRITE_POSITION_PATCH_FIELDS: &[&str] = &[
+ "dx",
+ "dy",
+ "width",
+ "height",
+ "rotation",
+ "pivot",
+ "idleTransform",
+ "poses",
+ "pressDurationMs",
+ "transitionMs",
+ "transitionEasing",
+ "baseImage",
+ "referenceNaturalSize",
+];
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PreviewKind {
@@ -104,6 +121,7 @@ pub enum PreviewDomain {
StatPosition,
GraphPosition,
KnobPosition,
+ SpritePosition,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -425,10 +443,14 @@ fn validate_publish_request(request: &PreviewPublishRequest) -> Result<(), Strin
"preview target count exceeds {MAX_PREVIEW_TARGETS}"
));
}
+ let allowed_fields = match request.domain {
+ PreviewDomain::SpritePosition => SPRITE_POSITION_PATCH_FIELDS,
+ _ => KEY_POSITION_PATCH_FIELDS,
+ };
if let Some(field) = request
.patch
.keys()
- .find(|field| !KEY_POSITION_PATCH_FIELDS.contains(&field.as_str()))
+ .find(|field| !allowed_fields.contains(&field.as_str()))
{
return Err(format!("preview patch field '{field}' is not allowed"));
}
diff --git a/src-tauri/src/services/preview_broker/tests.rs b/src-tauri/src/services/preview_broker/tests.rs
index 76affd3be..51de0ebf2 100644
--- a/src-tauri/src/services/preview_broker/tests.rs
+++ b/src-tauri/src/services/preview_broker/tests.rs
@@ -114,6 +114,32 @@ fn publish_injects_source_label_and_defaults_to_patch() {
assert_eq!(messages[0].kind, PreviewKind::Patch);
}
+#[test]
+fn element_rotation_preview_round_trips_for_native_position_domains() {
+ let broker = PreviewBroker::default();
+ subscribe(&broker, "owner");
+ let messages = Arc::new(Mutex::new(Vec::new()));
+ broker
+ .subscribe("observer", recording_channel(messages.clone()))
+ .unwrap();
+ for domain in [
+ PreviewDomain::KeyPosition,
+ PreviewDomain::StatPosition,
+ PreviewDomain::GraphPosition,
+ PreviewDomain::KnobPosition,
+ PreviewDomain::SpritePosition,
+ ] {
+ let mut request = request_for_domain(&session_id(), 1, domain);
+ request.patch = Map::from_iter([("rotation".to_string(), Value::from(45.5))]);
+ broker.publish("owner", request).unwrap();
+ let messages = messages.lock();
+ let message = messages.last().unwrap();
+ assert_eq!(message.domain, domain);
+ assert_eq!(message.patch["rotation"], 45.5);
+ }
+ assert_eq!(messages.lock().len(), 5);
+}
+
#[test]
fn image_transform_preview_fields_round_trip_through_channel() {
let broker = PreviewBroker::default();
@@ -272,6 +298,7 @@ fn forwards_every_position_preview_domain() {
PreviewDomain::StatPosition,
PreviewDomain::GraphPosition,
PreviewDomain::KnobPosition,
+ PreviewDomain::SpritePosition,
];
for (index, domain) in domains.iter().copied().enumerate() {
@@ -291,6 +318,127 @@ fn forwards_every_position_preview_domain() {
assert_eq!(forwarded, domains);
}
+#[test]
+fn sprite_position_domain_validates_and_round_trips_through_subscribers() {
+ let broker = PreviewBroker::default();
+ subscribe(&broker, "owner");
+ let messages = Arc::new(Mutex::new(Vec::new()));
+ broker
+ .subscribe("observer", recording_channel(messages.clone()))
+ .expect("observer subscribes");
+ let session_id = session_id();
+ let patch = serde_json::json!({
+ "dx": 24,
+ "dy": -8,
+ "width": 320,
+ "height": 180,
+ "rotation": -45.5,
+ "pivot": { "x": 0.5, "y": 0.75 },
+ "idleTransform": { "x": 0, "y": 0, "rotation": 0, "scale": 1 },
+ "poses": [{ "poseId": "pose-id", "triggers": [] }],
+ "pressDurationMs": 300,
+ "transitionMs": 120,
+ "transitionEasing": "ease-out",
+ "baseImage": "/images/base.png",
+ "referenceNaturalSize": {
+ "source": "/images/base.png",
+ "width": 640,
+ "height": 360
+ }
+ });
+ let request: PreviewPublishRequest = serde_json::from_value(serde_json::json!({
+ "schemaVersion": PREVIEW_SCHEMA_VERSION,
+ "sessionId": session_id,
+ "seq": 1,
+ "domain": "spritePosition",
+ "mode": "4key",
+ "targets": [0],
+ "patch": patch
+ }))
+ .expect("spritePosition domain is accepted");
+
+ broker
+ .publish("owner", request)
+ .expect("sprite preview publishes");
+
+ let messages = messages.lock();
+ assert_eq!(messages.len(), 1);
+ assert_eq!(messages[0].domain, PreviewDomain::SpritePosition);
+ assert_eq!(messages[0].mode, "4key");
+ assert_eq!(Value::Object(messages[0].patch.clone()), patch);
+}
+
+#[test]
+fn sprite_position_preview_allowlist_matches_supported_contract() {
+ assert_eq!(
+ SPRITE_POSITION_PATCH_FIELDS,
+ [
+ "dx",
+ "dy",
+ "width",
+ "height",
+ "rotation",
+ "pivot",
+ "idleTransform",
+ "poses",
+ "pressDurationMs",
+ "transitionMs",
+ "transitionEasing",
+ "baseImage",
+ "referenceNaturalSize",
+ ]
+ );
+}
+
+#[test]
+fn preview_patch_allowlist_rejects_unknown_and_cross_domain_fields() {
+ let broker = PreviewBroker::default();
+ subscribe(&broker, "owner");
+ let session_id = session_id();
+ let mut unknown_sprite = request_for_domain(&session_id, 1, PreviewDomain::SpritePosition);
+ unknown_sprite.patch = Map::from_iter([("unknownSpriteField".to_string(), Value::Bool(true))]);
+
+ let unknown_error = broker.publish("owner", unknown_sprite).unwrap_err();
+
+ assert!(unknown_error.contains("unknownSpriteField"));
+
+ for (seq, field) in ["x", "y", "scale"].into_iter().enumerate() {
+ let mut removed_field =
+ request_for_domain(&session_id, seq as u64 + 2, PreviewDomain::SpritePosition);
+ removed_field.patch = Map::from_iter([(field.to_string(), serde_json::json!(1.0))]);
+
+ let removed_error = broker.publish("owner", removed_field).unwrap_err();
+
+ assert!(removed_error.contains(field));
+ }
+
+ let mut sprite_field_on_key = request_for_domain(&session_id, 6, PreviewDomain::KeyPosition);
+ sprite_field_on_key.patch = Map::from_iter([(
+ "pivot".to_string(),
+ serde_json::json!({ "x": 0.5, "y": 0.5 }),
+ )]);
+
+ let cross_domain_error = broker.publish("owner", sprite_field_on_key).unwrap_err();
+
+ assert!(cross_domain_error.contains("pivot"));
+}
+
+#[test]
+fn unknown_preview_domain_is_rejected_during_wire_validation() {
+ let error = serde_json::from_value::(serde_json::json!({
+ "schemaVersion": PREVIEW_SCHEMA_VERSION,
+ "sessionId": session_id(),
+ "seq": 1,
+ "domain": "spritePose",
+ "mode": "4key",
+ "targets": [0],
+ "patch": { "dx": 24 }
+ }))
+ .expect_err("unknown preview domains stay closed");
+
+ assert!(error.to_string().contains("unknown variant"));
+}
+
#[test]
fn rejects_non_monotonic_sequence() {
let broker = PreviewBroker::default();
diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs
index 7af2ceb68..3be9c97e5 100644
--- a/src-tauri/src/state/app_state.rs
+++ b/src-tauri/src/state/app_state.rs
@@ -86,14 +86,15 @@ use window_geometry::convert_physical_bounds_to_logical;
#[cfg(all(target_os = "windows", not(test)))]
use window_geometry::OverlayRestoreSource;
use window_geometry::{
- applied_overlay_frame_from_placement, complete_overlay_scale_resolution,
- flush_deferred_overlay_bounds, logical_position_to_native, main_window_content_origin,
- main_window_logical_rect, main_window_native_rect, native_placement_from_window,
- overlay_restore_window_scale, panel_bounds_sample_from_window, panel_client_to_outer_position,
- persist_overlay_placement, persist_overlay_placement_from_window, resolve_overlay_placement,
- resolve_panel_window_layout, AppliedOverlayFrame, MonitorData, NativePlacement, NativeRect,
- OverlayPersistenceAuthority, OverlayPlacementTrust, OverlayPosition, PanelBoundsChange,
- PanelBoundsPersistenceController, PanelWindowLayout, ResolvedOverlayPlacement,
+ adjust_overlay_resize_position, applied_overlay_frame_from_placement,
+ complete_overlay_scale_resolution, content_offset_change, flush_deferred_overlay_bounds,
+ logical_position_to_native, main_window_content_origin, main_window_logical_rect,
+ main_window_native_rect, native_placement_from_window, overlay_restore_window_scale,
+ panel_bounds_sample_from_window, panel_client_to_outer_position, persist_overlay_placement,
+ persist_overlay_placement_from_window, resolve_overlay_placement, resolve_panel_window_layout,
+ AppliedOverlayFrame, MonitorData, NativePlacement, NativeRect, OverlayPersistenceAuthority,
+ OverlayPlacementTrust, OverlayPosition, PanelBoundsChange, PanelBoundsPersistenceController,
+ PanelWindowLayout, ResolvedOverlayPlacement,
};
#[cfg(test)]
use window_geometry::{
@@ -141,6 +142,11 @@ use crate::{
const OVERLAY_LABEL: &str = "overlay";
pub(crate) const PANEL_LABEL: &str = "panel";
+// Windows 창 클래스. OBS 윈도우 캡처는 기본 우선순위에서 같은 클래스의 창을 대체 후보로
+// 잡으므로 창마다 다른 이름을 준다(메인은 tauri.conf.json). OBS 씬에 저장되는 값이라
+// 출시 후 바꾸지 않는다
+const OVERLAY_WINDOW_CLASS: &str = "DmNoteOverlayWindow";
+const PANEL_WINDOW_CLASS: &str = "DmNotePanelWindow";
const FRONTEND_LIFECYCLE_WINDOW_LABELS: [&str; 2] = ["main", OVERLAY_LABEL];
// 메인이 window.open을 부르기 직전 arm하고, 이 시간 안에 온 요청만 패널 창으로 인정
const PANEL_OPEN_ARM_TIMEOUT: Duration = Duration::from_secs(2);
@@ -1081,6 +1087,7 @@ impl AppState {
stat_positions: state.stat_positions.clone(),
graph_positions: state.graph_positions.clone(),
knob_positions: state.knob_positions.clone(),
+ sprite_positions: state.sprite_positions.clone(),
custom_tabs: state.custom_tabs.clone(),
tab_order: state.tab_order.clone(),
bar_count: state.bar_count,
diff --git a/src-tauri/src/state/app_state/native_window.rs b/src-tauri/src/state/app_state/native_window.rs
index 442e52487..8409af6f3 100644
--- a/src-tauri/src/state/app_state/native_window.rs
+++ b/src-tauri/src/state/app_state/native_window.rs
@@ -262,6 +262,12 @@ pub(super) fn apply_macos_overlay_fullscreen_behavior(window: &WebviewWindow, al
let window = window.clone();
let _ = app.run_on_main_thread(move || {
apply_macos_overlay_fullscreen_behavior_inner(&window, always_on_top);
+ let app = window.app_handle();
+ if let Some(state) = app.try_state::() {
+ if let Err(error) = state.overlay_hit.reconcile_after_parent_order(app) {
+ log::warn!("failed to follow macOS overlay window ordering: {error:#}");
+ }
+ }
});
}
@@ -490,6 +496,7 @@ unsafe extern "system" fn overlay_move_subclass_proc(
&(*context).trust,
frame,
None,
+ None,
OverlayPersistenceAuthority::NativeMoveEnded,
)
}) {
diff --git a/src-tauri/src/state/app_state/overlay_runtime.rs b/src-tauri/src/state/app_state/overlay_runtime.rs
index 6cd3803f4..88e019a42 100644
--- a/src-tauri/src/state/app_state/overlay_runtime.rs
+++ b/src-tauri/src/state/app_state/overlay_runtime.rs
@@ -372,6 +372,7 @@ impl AppState {
width: f64,
height: f64,
anchor: Option,
+ content_left_offset: Option,
content_top_offset: Option,
fixed_position_delta_x: Option,
fixed_position_delta_y: Option,
@@ -413,70 +414,31 @@ impl AppState {
..current
}
};
- let mut next_content_top_offset = None;
+ // 직전 오프셋 두 개만 읽는다, 리사이즈마다 store 전체를 복제하지 않도록
+ let (last_content_left_offset, last_content_top_offset) = self.store.with_state(|state| {
+ (
+ state.overlay_last_content_left_offset,
+ state.overlay_last_content_top_offset,
+ )
+ });
+ let content_left_change =
+ content_offset_change(content_left_offset, last_content_left_offset);
+ let content_top_change = content_offset_change(content_top_offset, last_content_top_offset);
+ let next_content_left_offset = content_left_change.map(|(offset, _)| offset);
+ let next_content_top_offset = content_top_change.map(|(offset, _)| offset);
// 초기화 중(첫 resize)에는 anchor 기반 position 재계산을 건너뛰고
// 기동 시 한 번 해석한 배치를 사용
- if initializing {
- // 초기화 중이라도 content_top_offset은 저장해야 다음 resize에서 delta 계산이 정확함
- if let Some(offset) = content_top_offset {
- if offset.is_finite() {
- next_content_top_offset = Some(offset);
- }
- }
- } else {
- let scale = placement.target_scale;
- match anchor {
- OverlayResizeAnchor::BottomLeft => {
- placement.position.y += (current.height - height) * scale
- }
- OverlayResizeAnchor::TopRight => {
- placement.position.x += (current.width - width) * scale
- }
- OverlayResizeAnchor::BottomRight => {
- placement.position.x += (current.width - width) * scale;
- placement.position.y += (current.height - height) * scale;
- }
- OverlayResizeAnchor::Center => {
- placement.position.x += (current.width - width) * scale / 2.0;
- placement.position.y += (current.height - height) * scale / 2.0;
- }
- OverlayResizeAnchor::FixedPosition => {}
- OverlayResizeAnchor::TopLeft => {}
- }
-
- if anchor == OverlayResizeAnchor::FixedPosition {
- if let Some(delta_x) = fixed_position_delta_x.filter(|value| value.is_finite()) {
- placement.position.x += delta_x * scale;
- }
- if let Some(delta_y) = fixed_position_delta_y.filter(|value| value.is_finite()) {
- placement.position.y += delta_y * scale;
- }
- }
-
- if let Some(offset) = content_top_offset {
- if offset.is_finite() {
- let previous = self
- .store
- .snapshot()
- .overlay_last_content_top_offset
- .unwrap_or(offset);
- let delta = offset - previous;
- if delta != 0.0 {
- match anchor {
- OverlayResizeAnchor::Center => {
- placement.position.y -= delta * scale / 2.0
- }
- OverlayResizeAnchor::BottomLeft | OverlayResizeAnchor::BottomRight => {}
- OverlayResizeAnchor::FixedPosition => {
- placement.position.y -= delta * scale
- }
- _ => placement.position.y -= delta * scale,
- }
- }
- next_content_top_offset = Some(offset);
- }
- }
+ if !initializing {
+ adjust_overlay_resize_position(
+ &mut placement,
+ current,
+ &anchor,
+ fixed_position_delta_x.filter(|value| value.is_finite()),
+ fixed_position_delta_y.filter(|value| value.is_finite()),
+ content_left_change.map(|(_, delta)| delta),
+ content_top_change.map(|(_, delta)| delta),
+ );
}
// 크기·위치를 단일 네이티브 트랜잭션으로 적용 - 분리 호출은 창이 두 단계로 움직여 덜컥거림 유발
@@ -490,6 +452,7 @@ impl AppState {
&self.overlay_bounds_generation,
&self.overlay_placement_trust,
applied.clone(),
+ next_content_left_offset,
next_content_top_offset,
OverlayPersistenceAuthority::General,
)?;
@@ -583,6 +546,7 @@ impl AppState {
&self.overlay_placement_trust,
applied.clone(),
None,
+ None,
OverlayPersistenceAuthority::Reset,
)?;
let bounds = applied.public_bounds;
@@ -631,6 +595,7 @@ impl AppState {
WebviewUrl::App("overlay/index.html".into()),
)
.title("DM Note - Overlay")
+ .window_classname(OVERLAY_WINDOW_CLASS)
.decorations(false)
.resizable(false)
.maximizable(false)
@@ -786,6 +751,7 @@ impl AppState {
&self.overlay_placement_trust,
applied,
None,
+ None,
OverlayPersistenceAuthority::General,
) {
log::warn!("failed to persist initial overlay bounds: {err}");
diff --git a/src-tauri/src/state/app_state/panel_runtime.rs b/src-tauri/src/state/app_state/panel_runtime.rs
index 96b466a45..2dc36c4cb 100644
--- a/src-tauri/src/state/app_state/panel_runtime.rs
+++ b/src-tauri/src/state/app_state/panel_runtime.rs
@@ -452,6 +452,7 @@ impl AppState {
)
.window_features(features)
.title("DM Note - Panel")
+ .window_classname(PANEL_WINDOW_CLASS)
// 메인·오버레이와 같은 프레임리스 크롬 - 드래그 영역은 패널 상단 스트립이 담당
.decorations(false)
// Windows는 DWM이 실루엣을 소유하므로(windows_window_corners) 모서리 바깥을 비출
diff --git a/src-tauri/src/state/app_state/tests.rs b/src-tauri/src/state/app_state/tests.rs
index 927b741de..d0ce22a62 100644
--- a/src-tauri/src/state/app_state/tests.rs
+++ b/src-tauri/src/state/app_state/tests.rs
@@ -12,18 +12,18 @@ use std::{
use super::normalize_stored_overlay_bounds;
use super::{
acknowledge_editor_flush_handshake, acknowledge_panel_close_request,
- applied_overlay_frame_from_native, apply_panel_bounds_change, begin_panel_close_request,
- bootstrap_keyboard_state, canonical_hold_duration_ms, changed_panel_max_height,
- clamp_overlay_dimension, collect_authorized_css_paths, collect_frontend_lifecycle_targets,
- complete_overlay_scale_resolution, drop_panel_hidden_with_main,
- frontend_history_mutation_blocked, frontend_lifecycle_restore_labels, global_css_watch_path,
- hide_panel_with_main_transition, install_history_handshake, install_lifecycle_handshake,
- is_panel_open_url, key_state_payload, main_window_starts_hidden, monitor_scale_is_usable,
- next_keyboard_recovery_plan, next_overlay_placement_trust, overlay_close_action,
- overlay_reset_fallback_rect, panel_bounds_from_sample, panel_height_bounds,
- panel_position_beside_main, publish_panel_hidden_transition,
- publish_panel_visibility_transition, resolve_event_age_ms, resolve_overlay_placement,
- resolve_panel_window_layout, resolve_windows_overlay_placement,
+ adjust_overlay_resize_position, applied_overlay_frame_from_native, apply_panel_bounds_change,
+ begin_panel_close_request, bootstrap_keyboard_state, canonical_hold_duration_ms,
+ changed_panel_max_height, clamp_overlay_dimension, collect_authorized_css_paths,
+ collect_frontend_lifecycle_targets, complete_overlay_scale_resolution, content_offset_change,
+ drop_panel_hidden_with_main, frontend_history_mutation_blocked,
+ frontend_lifecycle_restore_labels, global_css_watch_path, hide_panel_with_main_transition,
+ install_history_handshake, install_lifecycle_handshake, is_panel_open_url, key_state_payload,
+ main_window_starts_hidden, monitor_scale_is_usable, next_keyboard_recovery_plan,
+ next_overlay_placement_trust, overlay_close_action, overlay_reset_fallback_rect,
+ panel_bounds_from_sample, panel_height_bounds, panel_position_beside_main,
+ publish_panel_hidden_transition, publish_panel_visibility_transition, resolve_event_age_ms,
+ resolve_overlay_placement, resolve_panel_window_layout, resolve_windows_overlay_placement,
restore_panel_with_main_transition, run_panel_close_timeout, should_create_overlay_on_startup,
should_recover_keyboard_daemon, should_restore_panel_on_startup,
stored_overlay_bounds_for_persistence, take_cancelable_editor_flush_handshake,
@@ -31,23 +31,24 @@ use super::{
EditorFlushCompletion, EditorFlushHandshake, EditorFlushRequest, FrontendFlushAction,
FrontendHistoryFlushPhase, FrontendHistoryFlushReady, FrontendLifecycleAction,
KeyCounterEventEmitter, LifecycleHandshakeInstall, MonitorData, MonitorSpec,
- MutationPublicationSequencer, Mutex, NativeRect, NativeRejectReason, OverlayCloseAction,
- OverlayPersistenceAuthority, OverlayPlacementTrust, OverlayRestoreSource, PanelBoundsChange,
- PanelBoundsPersistenceController, PanelBoundsPersistenceState, PanelBoundsSample,
- PanelCloseRequestState, PanelCloseRequestedPayload, PanelPresentSnapshot,
- PanelVisibilityEventEmitter, PanelVisibilityPayload, PanelVisibilityReason, PhysicalPosition,
- PhysicalSize, DEFAULT_OVERLAY_HEIGHT, DEFAULT_OVERLAY_WIDTH,
- HISTORY_FRONTEND_FLUSH_INTERRUPTED, KEYBOARD_DAEMON_STABLE_RUNTIME,
- KEYBOARD_RECOVERY_DELAYS_MS, OVERLAY_LABEL, PANEL_BESIDE_GAP, PANEL_INITIAL_HEIGHT,
- PANEL_LABEL, PANEL_MIN_HEIGHT, PANEL_OPEN_ARM_TIMEOUT, PANEL_WIDTH,
+ MutationPublicationSequencer, Mutex, NativePlacement, NativeRect, NativeRejectReason,
+ OverlayCloseAction, OverlayPersistenceAuthority, OverlayPlacementTrust, OverlayPosition,
+ OverlayRestoreSource, PanelBoundsChange, PanelBoundsPersistenceController,
+ PanelBoundsPersistenceState, PanelBoundsSample, PanelCloseRequestState,
+ PanelCloseRequestedPayload, PanelPresentSnapshot, PanelVisibilityEventEmitter,
+ PanelVisibilityPayload, PanelVisibilityReason, PhysicalPosition, PhysicalSize,
+ DEFAULT_OVERLAY_HEIGHT, DEFAULT_OVERLAY_WIDTH, HISTORY_FRONTEND_FLUSH_INTERRUPTED,
+ KEYBOARD_DAEMON_STABLE_RUNTIME, KEYBOARD_RECOVERY_DELAYS_MS, OVERLAY_LABEL, PANEL_BESIDE_GAP,
+ PANEL_INITIAL_HEIGHT, PANEL_LABEL, PANEL_MIN_HEIGHT, PANEL_OPEN_ARM_TIMEOUT, PANEL_WIDTH,
};
use crate::{
keyboard::KeyboardManager,
models::{
AppStoreData, CustomCss, CustomTab, EditorCommitOrigin, EditorCommitRequest, EditorField,
EditorFrozenKeySlotV1, EditorOpV1, GestureCommitRequest, GesturePluginInstancesChange,
- KeyCounters, KeySlot, OverlayBounds, PanelBounds, PluginPoint, SavedPluginInstance,
- StoredOverlayBounds, StoredOverlayNativePosition, TabCss, EDITOR_OPS_VERSION,
+ KeyCounters, KeySlot, OverlayBounds, OverlayResizeAnchor, PanelBounds, PluginPoint,
+ SavedPluginInstance, StoredOverlayBounds, StoredOverlayNativePosition, TabCss,
+ EDITOR_OPS_VERSION,
},
state::{
assets::local_asset_path::path_identity_key,
@@ -62,6 +63,123 @@ use std::path::Path;
struct NoopCounterEmitter;
+fn resize_placement(x: f64, y: f64) -> NativePlacement {
+ NativePlacement {
+ position: OverlayPosition { x, y },
+ width: 100.0,
+ height: 100.0,
+ target_scale: 2.0,
+ }
+}
+
+#[test]
+fn unchanged_content_top_baseline_has_zero_first_delta() {
+ assert_eq!(
+ content_offset_change(Some(24.0), Some(24.0)),
+ Some((24.0, 0.0))
+ );
+ assert_eq!(content_offset_change(Some(24.0), None), Some((24.0, 0.0)));
+}
+
+#[test]
+fn top_left_resize_compensates_left_and_top_overhang() {
+ let current = resize_placement(100.0, 200.0);
+ let mut placement = NativePlacement {
+ width: 140.0,
+ height: 160.0,
+ ..current
+ };
+
+ adjust_overlay_resize_position(
+ &mut placement,
+ current,
+ &OverlayResizeAnchor::TopLeft,
+ None,
+ None,
+ Some(10.0),
+ Some(20.0),
+ );
+
+ assert_eq!(placement.position, OverlayPosition { x: 80.0, y: 160.0 });
+}
+
+#[test]
+fn fixed_position_resize_applies_fixed_and_content_deltas_once() {
+ let current = resize_placement(100.0, 200.0);
+ let mut placement = current;
+
+ adjust_overlay_resize_position(
+ &mut placement,
+ current,
+ &OverlayResizeAnchor::FixedPosition,
+ Some(3.0),
+ Some(4.0),
+ Some(10.0),
+ Some(20.0),
+ );
+
+ assert_eq!(placement.position, OverlayPosition { x: 86.0, y: 168.0 });
+}
+
+#[cfg(target_os = "macos")]
+#[test]
+fn macos_sprite_visibility_roundtrip_keeps_overlay_position() {
+ // AppKit의 원점 내림과 크기 올림을 실제 창 대조 실행과 같은 순서로 반영
+ let apply_native = |mut placement: NativePlacement| {
+ let screen_height = 1440.0;
+ let bottom = (screen_height - placement.position.y - placement.height).floor();
+ placement.position.x = placement.position.x.floor();
+ placement.width = placement.width.ceil();
+ placement.height = placement.height.ceil();
+ placement.position.y = screen_height - bottom - placement.height;
+ placement
+ };
+ for anchor in [
+ OverlayResizeAnchor::TopLeft,
+ OverlayResizeAnchor::TopRight,
+ OverlayResizeAnchor::BottomLeft,
+ OverlayResizeAnchor::BottomRight,
+ OverlayResizeAnchor::Center,
+ OverlayResizeAnchor::FixedPosition,
+ ] {
+ for origin in [100.0, -100.0] {
+ for delta in [2.5776543848328117, 0.5, -0.5, 17.125] {
+ let original = NativePlacement {
+ position: OverlayPosition {
+ x: origin,
+ y: origin,
+ },
+ width: 655.0,
+ height: 505.0,
+ target_scale: 1.0,
+ };
+ let mut current = original;
+ for _ in 0..100 {
+ for visible in [true, false] {
+ let change = if visible { delta } else { -delta };
+ let mut placement = NativePlacement {
+ width: if visible { 798.0 } else { 655.0 },
+ height: if visible { 663.0 } else { 505.0 },
+ ..current
+ };
+ adjust_overlay_resize_position(
+ &mut placement,
+ current,
+ &anchor,
+ Some(change),
+ Some(-change),
+ Some(change),
+ Some(change),
+ );
+ current = apply_native(placement);
+ }
+ assert_eq!(current, original, "anchor={anchor:?}, delta={delta}");
+ }
+ }
+ }
+ }
+}
+
impl KeyCounterEventEmitter for NoopCounterEmitter {
fn emit_key_counters(
&self,
@@ -2241,6 +2359,129 @@ fn lifecycle_flush_interrupts_history_but_history_cannot_replace_lifecycle() {
.is_some_and(|active| active.completion.is_lifecycle()));
}
+#[cfg(unix)]
+#[test]
+fn sigterm_preserves_pending_state_and_allows_retry_after_canceled_flush() {
+ use std::{os::unix::process::ExitStatusExt, process::Command};
+
+ const CHILD_MODE: &str = "DMNOTE_SIGTERM_REGRESSION_MODE";
+ const CHILD_STORE: &str = "DMNOTE_SIGTERM_REGRESSION_STORE";
+ if let Ok(mode) = std::env::var(CHILD_MODE) {
+ let directory = std::env::var_os(CHILD_STORE).unwrap();
+ let state = Arc::new(
+ AppState::initialize(AppStore::initialize_for_test(Path::new(&directory)).unwrap())
+ .unwrap(),
+ );
+ state.key_counter_enabled.store(true, Ordering::SeqCst);
+ let snapshot = state.store.snapshot();
+ let key_mode = snapshot.selected_key_type;
+ let key = snapshot.keys[&key_mode][0].canonical();
+ for expected in 1..=9 {
+ assert_eq!(
+ state.increment_key_counter_and_emit(&NoopCounterEmitter, &key_mode, &key),
+ Some(expected)
+ );
+ }
+ state.store.flush().unwrap();
+ state
+ .store
+ .update_deferred(|store| {
+ store.overlay_bounds = Some(StoredOverlayBounds {
+ x: 417.0,
+ y: 19.0,
+ width: 500.0,
+ height: 400.0,
+ native_position: None,
+ });
+ })
+ .unwrap();
+
+ let (sender, receiver) = mpsc::channel();
+ if mode == "protected" {
+ let state = Arc::clone(&state);
+ let requests = AtomicUsize::new(0);
+ crate::state::window::unix_termination::install(move || {
+ let request_number = requests.fetch_add(1, Ordering::SeqCst) + 1;
+ let mut active = state.editor_flush_handshake.lock();
+ let outcome = install_lifecycle_handshake(
+ &mut active,
+ EditorFlushHandshake {
+ id: format!("sigterm-{request_number}"),
+ completion: EditorFlushCompletion::Lifecycle(FrontendLifecycleAction::Quit),
+ target_windows: HashSet::from(["main".to_string()]),
+ pending_windows: HashSet::from(["main".to_string()]),
+ },
+ );
+ let installed = matches!(outcome, LifecycleHandshakeInstall::Installed);
+ sender
+ .send((active.as_ref().unwrap().id.clone(), installed))
+ .unwrap();
+ })
+ .unwrap();
+ }
+
+ assert_eq!(unsafe { libc::raise(libc::SIGTERM) }, 0);
+ assert_eq!(
+ receiver.recv_timeout(Duration::from_secs(3)).unwrap(),
+ ("sigterm-1".to_string(), true)
+ );
+ assert_eq!(unsafe { libc::raise(libc::SIGTERM) }, 0);
+ assert_eq!(
+ receiver.recv_timeout(Duration::from_secs(3)).unwrap(),
+ ("sigterm-1".to_string(), false)
+ );
+ assert!(take_cancelable_editor_flush_handshake(
+ &mut state.editor_flush_handshake.lock(),
+ "sigterm-1"
+ )
+ .is_some());
+ assert_eq!(unsafe { libc::raise(libc::SIGTERM) }, 0);
+ assert_eq!(
+ receiver.recv_timeout(Duration::from_secs(3)).unwrap(),
+ ("sigterm-3".to_string(), true)
+ );
+ state.shutdown();
+ return;
+ }
+
+ for mode in ["default", "protected"] {
+ let directory = tempfile::tempdir().unwrap();
+ let output = Command::new(std::env::current_exe().unwrap())
+ .args([
+ "--exact",
+ "state::app_state::tests::sigterm_preserves_pending_state_and_allows_retry_after_canceled_flush",
+ "--nocapture",
+ ])
+ .env(CHILD_MODE, mode)
+ .env(CHILD_STORE, directory.path())
+ .output()
+ .unwrap();
+ if mode == "default" {
+ assert_eq!(output.status.signal(), Some(libc::SIGTERM));
+ } else {
+ assert!(
+ output.status.success(),
+ "SIGTERM child failed: {:?}\n{}\n{}",
+ output.status,
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ }
+ let persisted: AppStoreData =
+ serde_json::from_slice(&std::fs::read(directory.path().join("store.json")).unwrap())
+ .unwrap();
+ let key = persisted.keys[&persisted.selected_key_type][0].canonical();
+ let counter = persisted.key_counters[&persisted.selected_key_type][&key];
+ let x = persisted.overlay_bounds.as_ref().map(|bounds| bounds.x);
+ eprintln!(
+ "SIGTERM {mode}: status={}, counter={counter}, bounds_x={x:?}",
+ output.status
+ );
+ assert_eq!(counter, if mode == "protected" { 9 } else { 0 });
+ assert_eq!(x, (mode == "protected").then_some(417.0));
+ }
+}
+
#[test]
fn closing_history_can_time_out_and_reopen_the_gate() {
let (sender, _receiver) = tokio::sync::oneshot::channel();
diff --git a/src-tauri/src/state/app_state/window_geometry/overlay_placement.rs b/src-tauri/src/state/app_state/window_geometry/overlay_placement.rs
index 593c81a4c..4d6d2857d 100644
--- a/src-tauri/src/state/app_state/window_geometry/overlay_placement.rs
+++ b/src-tauri/src/state/app_state/window_geometry/overlay_placement.rs
@@ -120,6 +120,79 @@ impl NativePlacement {
}
}
+pub(in crate::state::app_state) fn content_offset_change(
+ offset: Option,
+ previous: Option,
+) -> Option<(f64, f64)> {
+ let offset = offset.filter(|value| value.is_finite())?;
+ Some((offset, offset - previous.unwrap_or(offset)))
+}
+
+pub(in crate::state::app_state) fn adjust_overlay_resize_position(
+ placement: &mut NativePlacement,
+ current: NativePlacement,
+ anchor: &OverlayResizeAnchor,
+ fixed_position_delta_x: Option,
+ fixed_position_delta_y: Option,
+ content_left_delta: Option,
+ content_top_delta: Option,
+) {
+ let scale = placement.target_scale;
+ match anchor {
+ OverlayResizeAnchor::BottomLeft => {
+ placement.position.y += (current.height - placement.height) * scale
+ }
+ OverlayResizeAnchor::TopRight => {
+ placement.position.x += (current.width - placement.width) * scale
+ }
+ OverlayResizeAnchor::BottomRight => {
+ placement.position.x += (current.width - placement.width) * scale;
+ placement.position.y += (current.height - placement.height) * scale;
+ }
+ OverlayResizeAnchor::Center => {
+ placement.position.x += (current.width - placement.width) * scale / 2.0;
+ placement.position.y += (current.height - placement.height) * scale / 2.0;
+ }
+ OverlayResizeAnchor::FixedPosition | OverlayResizeAnchor::TopLeft => {}
+ }
+
+ if matches!(anchor, OverlayResizeAnchor::FixedPosition) {
+ if let Some(delta_x) = fixed_position_delta_x {
+ placement.position.x += delta_x * scale;
+ }
+ if let Some(delta_y) = fixed_position_delta_y {
+ placement.position.y += delta_y * scale;
+ }
+ }
+
+ if let Some(delta) = content_left_delta.filter(|delta| *delta != 0.0) {
+ match anchor {
+ OverlayResizeAnchor::Center => placement.position.x -= delta * scale / 2.0,
+ OverlayResizeAnchor::TopRight | OverlayResizeAnchor::BottomRight => {}
+ OverlayResizeAnchor::FixedPosition => placement.position.x -= delta * scale,
+ _ => placement.position.x -= delta * scale,
+ }
+ }
+
+ if let Some(delta) = content_top_delta.filter(|delta| *delta != 0.0) {
+ match anchor {
+ OverlayResizeAnchor::Center => placement.position.y -= delta * scale / 2.0,
+ OverlayResizeAnchor::BottomLeft | OverlayResizeAnchor::BottomRight => {}
+ OverlayResizeAnchor::FixedPosition => placement.position.y -= delta * scale,
+ _ => placement.position.y -= delta * scale,
+ }
+ }
+
+ #[cfg(target_os = "macos")]
+ {
+ // AppKit의 원점 내림 전에 이동량을 대칭 반올림해 왕복 시 위치 누적 방지
+ placement.position.x =
+ current.position.x + (placement.position.x - current.position.x).round();
+ placement.position.y =
+ current.position.y + (placement.position.y - current.position.y).round();
+ }
+}
+
#[derive(Clone, Debug)]
pub(in crate::state::app_state) enum PendingOverlayScaleResolution {
#[cfg(any(target_os = "windows", test))]
@@ -824,7 +897,7 @@ pub(in crate::state::app_state) fn persist_overlay_placement_from_window(
authority: OverlayPersistenceAuthority,
) -> Result<()> {
let frame = applied_overlay_frame_from_window(window)?;
- persist_overlay_placement(store, generation, trust, frame, None, authority)
+ persist_overlay_placement(store, generation, trust, frame, None, None, authority)
}
pub(in crate::state::app_state) fn persist_overlay_placement(
@@ -832,6 +905,7 @@ pub(in crate::state::app_state) fn persist_overlay_placement(
generation: &Arc,
trust: &Arc>,
frame: AppliedOverlayFrame,
+ content_left_offset: Option,
content_top_offset: Option,
authority: OverlayPersistenceAuthority,
) -> Result<()> {
@@ -846,6 +920,9 @@ pub(in crate::state::app_state) fn persist_overlay_placement(
store.update_deferred(move |state| {
state.overlay_bounds = Some(stored);
state.overlay_bounds_are_logical = true;
+ if let Some(offset) = content_left_offset {
+ state.overlay_last_content_left_offset = Some(offset);
+ }
if let Some(offset) = content_top_offset {
state.overlay_last_content_top_offset = Some(offset);
}
diff --git a/src-tauri/src/state/assets/image_asset.rs b/src-tauri/src/state/assets/image_asset.rs
index 80d527349..ef2f321ea 100644
--- a/src-tauri/src/state/assets/image_asset.rs
+++ b/src-tauri/src/state/assets/image_asset.rs
@@ -7,8 +7,47 @@ use std::{
use anyhow::{Context, Result};
use uuid::Uuid;
+use super::local_asset_path::{file_url_to_path, FileUrlPath};
use crate::state::atomic_file::{atomic_replace, atomic_replace_from_temp, sync_file_contents};
+// 이미지 선택창이 받는 확장자. OBS 브릿지 서빙 허용 목록도 이 목록을 따라야
+// 편집기·네이티브 오버레이에서 보이는 그림이 OBS에서만 빠지지 않는다
+pub(crate) const SUPPORTED_IMAGE_EXTENSIONS: &[&str] = &[
+ "png", "jpg", "jpeg", "webp", "gif", "bmp", "svg", "ico", "avif",
+];
+
+pub(crate) fn probe_local_raster_size(image_ref: &str) -> Result