Skip to main content

pixel8_runtime/
clipboard.rs

1//! Pixel8's clipboard data model and paste appliers, plus the native JSON
2//! clipboard codec.
3//!
4//! The value types here are format-neutral: PICO-8 `[gfx]`/`[sfx]` blobs decode
5//! into them (see [`crate::pico8`]) and so does Pixel8's native JSON format.
6
7use crate::{
8    assets::{
9        Assets, MusicPattern, Sfx, SpriteSheet, MAP_H, MAP_W, MUSIC_COUNT, SFX_COUNT, SHEET_H,
10        SHEET_W, SPRITES_PER_ROW, SPRITE_SIZE,
11    },
12    pico8::{next_free_sfx, remap_custom_instruments, remap_music_channels},
13};
14use anyhow::{Context, Result};
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17
18/// A `width * height` block of palette-index pixels, one per cell, row-major.
19pub struct PixelRect {
20    pub w: usize,
21    pub h: usize,
22    pub pixels: Vec<u8>,
23}
24
25/// One clipboard item tagged with the PICO-8 slot it was copied from.
26pub struct Slotted<T> {
27    pub src: u8,
28    pub value: T,
29}
30
31/// A decoded `[sfx]` blob: the SFX records plus any trailing pattern footers
32/// (present when the blob came from copying a song pattern). The patterns'
33/// channel refs hold PICO-8 *source* SFX slots until a paste remaps them.
34pub struct SfxClip {
35    pub records: Vec<Slotted<Sfx>>,
36    pub patterns: Vec<MusicPattern>,
37}
38
39/// One decoded clipboard blob, by kind.
40pub enum Pasted {
41    /// A pixel block; `flags` is `Some` only for native sprite copies (one byte
42    /// per covered 8x8 sprite), `None` for PICO-8 `[gfx]`.
43    Sprites {
44        rect: PixelRect,
45        flags: Option<Vec<u8>>,
46    },
47    Sfx(SfxClip),
48    /// A map tile region. Native only — PICO-8 has no map clipboard format.
49    Map {
50        w: usize,
51        h: usize,
52        tiles: Vec<u8>,
53    },
54}
55
56/// The outcome of a paste, ready for the editor's status bar.
57pub struct PasteReport {
58    /// A compact one-line summary that fits the status bar.
59    pub summary: String,
60    /// Items that did not fit (capacity) or fell outside the sheet/map.
61    pub clipped: usize,
62    /// Per-reference warnings; the count is folded into `summary`.
63    pub warnings: Vec<String>,
64}
65
66/// Blit a pixel rectangle with its top-left at sheet pixel `(x0, y0)`,
67/// overwriting in place. Pixels past the sheet edge are clipped. `flags` is
68/// one byte per covered 8x8 sprite (row-major); when `Some`, each byte
69/// overwrites the destination sprite's flag byte.
70pub fn paste_sprites(
71    sheet: &mut SpriteSheet,
72    rect: &PixelRect,
73    x0: i32,
74    y0: i32,
75    flags: Option<&[u8]>,
76) -> PasteReport {
77    let mut clipped = 0;
78    for ry in 0..rect.h {
79        for rx in 0..rect.w {
80            let (x, y) = (x0 + rx as i32, y0 + ry as i32);
81            if (0..SHEET_W as i32).contains(&x) && (0..SHEET_H as i32).contains(&y) {
82                sheet.set(x, y, rect.pixels[ry * rect.w + rx]);
83            } else {
84                clipped += 1;
85            }
86        }
87    }
88    let slot =
89        (y0.max(0) as usize / SPRITE_SIZE) * SPRITES_PER_ROW + (x0.max(0) as usize / SPRITE_SIZE);
90    // Native sprite copies carry one flag byte per covered 8x8 sprite, laid out
91    // row-major from the destination sprite; apply them onto the sheet.
92    if let Some(flags) = flags {
93        let cols = rect.w.div_ceil(SPRITE_SIZE);
94        let sprite_x0 = (x0.max(0) as usize) / SPRITE_SIZE;
95        let sprite_y0 = (y0.max(0) as usize) / SPRITE_SIZE;
96        for (i, &f) in flags.iter().enumerate() {
97            let n = (sprite_y0 + i / cols) * SPRITES_PER_ROW + (sprite_x0 + i % cols);
98            if n < sheet.flags.len() {
99                sheet.flags[n] = f;
100            }
101        }
102    }
103    PasteReport {
104        summary: rect_summary(rect.w, rect.h, clipped, slot),
105        clipped,
106        warnings: Vec::new(),
107    }
108}
109
110/// SFX-editor paste: overwrite SFX slots from `at` with `records`, remapping
111/// each record's custom-instrument note refs to the slots they land in. Records
112/// past slot 63 are dropped.
113pub fn paste_sfx(sfx: &mut [Sfx], records: &[Slotted<Sfx>], at: usize) -> PasteReport {
114    let fit = records.len().min(SFX_COUNT.saturating_sub(at));
115    let mut sfx_map: HashMap<u8, usize> = HashMap::new();
116    for (i, rec) in records.iter().take(fit).enumerate() {
117        sfx[at + i] = rec.value.clone();
118        sfx_map.insert(rec.src, at + i);
119    }
120    let mut warnings = Vec::new();
121    for (i, rec) in records.iter().take(fit).enumerate() {
122        remap_custom_instruments(
123            &mut sfx[at + i].notes,
124            &sfx_map,
125            &format!("SFX {}", rec.src),
126            &mut warnings,
127        );
128    }
129    let clipped = records.len() - fit;
130    PasteReport {
131        summary: seq_summary("SFX", at, fit, clipped, warnings.len()),
132        clipped,
133        warnings,
134    }
135}
136
137/// Music-editor paste: append the clip's SFX after the last used SFX slot
138/// (non-destructive), remap each footer pattern's channel refs to where those
139/// SFX landed, then overwrite music patterns from `at_pattern`.
140pub fn paste_pattern(assets: &mut Assets, clip: &SfxClip, at_pattern: usize) -> PasteReport {
141    let mut warnings = Vec::new();
142
143    let sfx_start = next_free_sfx(&assets.sfx);
144    let sfx_fit = clip.records.len().min(SFX_COUNT.saturating_sub(sfx_start));
145    let mut sfx_map: HashMap<u8, usize> = HashMap::new();
146    for (i, rec) in clip.records.iter().take(sfx_fit).enumerate() {
147        assets.sfx[sfx_start + i] = rec.value.clone();
148        sfx_map.insert(rec.src, sfx_start + i);
149    }
150    for (i, rec) in clip.records.iter().take(sfx_fit).enumerate() {
151        remap_custom_instruments(
152            &mut assets.sfx[sfx_start + i].notes,
153            &sfx_map,
154            &format!("SFX {}", rec.src),
155            &mut warnings,
156        );
157    }
158
159    let pat_fit = clip
160        .patterns
161        .len()
162        .min(MUSIC_COUNT.saturating_sub(at_pattern));
163    for (i, pat) in clip.patterns.iter().take(pat_fit).enumerate() {
164        let mut p = *pat;
165        remap_music_channels(&mut p, &sfx_map, &format!("pattern {i}"), &mut warnings);
166        assets.music[at_pattern + i] = p;
167    }
168
169    let clipped = (clip.records.len() - sfx_fit) + (clip.patterns.len() - pat_fit);
170    let summary = pattern_summary(at_pattern, pat_fit, sfx_fit, clipped, warnings.len());
171    PasteReport {
172        summary,
173        clipped,
174        warnings,
175    }
176}
177
178// The summaries below are deliberately terse so they always fit the 31-char
179// status bar (verified by `summaries_fit_the_status_bar`). Counts use compact
180// `Ncut`/`Nwarn` tokens rather than spelled-out words.
181
182/// A status-bar-safe rendering of a count: a literal up to 99, then `"99+"`,
183/// so summary width stays bounded no matter how many items were clipped or
184/// warned about.
185fn count(n: usize) -> String {
186    if n > 99 {
187        "99+".to_string()
188    } else {
189        n.to_string()
190    }
191}
192
193/// `"pasted 8x8 spr 1"`, plus ` Ncut` when some cells fell off the sheet.
194fn rect_summary(w: usize, h: usize, clipped: usize, slot: usize) -> String {
195    let mut s = format!("pasted {w}x{h} spr {slot}");
196    if clipped > 0 {
197        s.push_str(&format!(" {}cut", count(clipped)));
198    }
199    s
200}
201
202/// `"pasted SFX 3-6"` (or `"pasted SFX 3"` for one), plus ` Ncut`/` Nwarn` tails.
203/// The slot range implies the count, so it is not repeated.
204fn seq_summary(kind: &str, at: usize, fit: usize, clipped: usize, warns: usize) -> String {
205    let mut s = match fit {
206        0 => return format!("no free {kind} slot"),
207        1 => format!("pasted {kind} {at}"),
208        n => format!("pasted {kind} {at}-{}", at + n - 1),
209    };
210    if clipped > 0 {
211        s.push_str(&format!(" {}cut", count(clipped)));
212    }
213    if warns > 0 {
214        s.push_str(&format!(" {}warn", count(warns)));
215    }
216    s
217}
218
219/// `"pasted pat 5 +2sfx"` when clean; on issues it drops the SFX count to make
220/// room for ` Ncut`/` Nwarn`, e.g. `"pasted pat 5 1cut 3warn"`.
221fn pattern_summary(
222    at: usize,
223    pat_fit: usize,
224    sfx_fit: usize,
225    clipped: usize,
226    warns: usize,
227) -> String {
228    if pat_fit == 0 {
229        return if sfx_fit > 0 {
230            format!("pasted +{sfx_fit}sfx, no pattern")
231        } else {
232            "no pattern in clipboard".to_string()
233        };
234    }
235    if clipped == 0 && warns == 0 {
236        return format!("pasted pat {at} +{sfx_fit}sfx");
237    }
238    let mut s = format!("pasted pat {at}");
239    if clipped > 0 {
240        s.push_str(&format!(" {}cut", count(clipped)));
241    }
242    if warns > 0 {
243        s.push_str(&format!(" {}warn", count(warns)));
244    }
245    s
246}
247
248/// The text between `[tag]` and `[/tag]`, if both are present.
249pub(crate) fn tagged<'a>(text: &'a str, tag: &str) -> Option<&'a str> {
250    let open = format!("[{tag}]");
251    let close = format!("[/{tag}]");
252    let start = text.find(&open)? + open.len();
253    let end = text[start..].find(&close)? + start;
254    Some(&text[start..end])
255}
256
257/// Native clipboard format version; bump if `ClipboardPayload` changes shape.
258const VERSION: u32 = 1;
259
260/// The producer marker stamped into every native blob, so a decoder can tell a
261/// Pixel8 clipboard object from unrelated JSON sitting on the system clipboard.
262const APP: &str = "pixel8";
263
264/// The native clipboard envelope: an `"app"`/`"version"` marker pair ahead of the
265/// flattened payload. Serialises borrowed (encode) and owned (decode).
266#[derive(Serialize, Deserialize)]
267struct Blob<T> {
268    app: String,
269    version: u32,
270    #[serde(flatten)]
271    payload: T,
272}
273
274/// One copied item, tagged by kind. Reuses the asset structs, so
275/// `Sfx::custom_wave`, sprite flags, and 8-bit map tiles all survive.
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "snake_case")]
278pub enum ClipboardPayload {
279    /// A `w * h` pixel block plus one flag byte per covered 8x8 sprite.
280    Sprite {
281        w: u8,
282        h: u8,
283        #[serde(with = "crate::wire::hex_string")]
284        pixels: Vec<u8>,
285        #[serde(with = "crate::wire::hex_string")]
286        flags: Vec<u8>,
287    },
288    /// One SFX, full fidelity (includes `custom_wave`). `slot` is the source slot.
289    Sfx { slot: u8, sfx: crate::assets::Sfx },
290    /// A music pattern plus the SFX its channels reference (as `(slot, sfx)`).
291    Pattern {
292        pattern: MusicPattern,
293        sfx: Vec<(u8, crate::assets::Sfx)>,
294    },
295    /// A `w * h` block of 8-bit map tiles, row-major.
296    Map {
297        w: u8,
298        h: u8,
299        #[serde(with = "crate::wire::hex_string")]
300        tiles: Vec<u8>,
301    },
302}
303
304/// Encode a payload as a native `{ "app": "pixel8", … }` JSON clipboard blob.
305pub fn encode(payload: &ClipboardPayload) -> String {
306    let blob = Blob {
307        app: APP.to_string(),
308        version: VERSION,
309        payload,
310    };
311    // serde_json only errors on a serializer fault, which these owned, finite
312    // values cannot trigger.
313    serde_json::to_string(&blob).expect("clipboard payload serializes")
314}
315
316/// Decode a clipboard string into a `Pasted`: a native Pixel8 JSON blob (a single
317/// `{ … }` object), else a PICO-8 `[gfx]`/`[sfx]` blob. Any unrecognised or
318/// malformed text is an `Err`.
319pub fn parse(text: &str) -> Result<Pasted> {
320    let text = text.trim();
321    if text.starts_with('{') {
322        return Ok(decode_native(text)?.into_pasted());
323    }
324    crate::pico8::parse_clipboard(text)
325}
326
327/// Decode a native clipboard blob (a whole JSON object) into a payload. Total /
328/// panic-free.
329fn decode_native(json: &str) -> Result<ClipboardPayload> {
330    let blob: Blob<ClipboardPayload> =
331        serde_json::from_str(json).context("malformed clipboard payload")?;
332    if blob.app != APP {
333        anyhow::bail!("not a Pixel8 clipboard blob");
334    }
335    if blob.version != VERSION {
336        anyhow::bail!("unsupported clipboard version {}", blob.version);
337    }
338    let payload = blob.payload;
339    match &payload {
340        ClipboardPayload::Sprite { w, h, pixels, .. } => {
341            let (w, h) = (*w as usize, *h as usize);
342            if w == 0 || h == 0 || w > SHEET_W || h > SHEET_H || pixels.len() != w * h {
343                anyhow::bail!("clipboard sprite dimensions inconsistent with pixel data");
344            }
345        }
346        ClipboardPayload::Map { w, h, tiles } => {
347            let (w, h) = (*w as usize, *h as usize);
348            if w == 0 || h == 0 || w > MAP_W || h > MAP_H || tiles.len() != w * h {
349                anyhow::bail!("clipboard map dimensions inconsistent with tile data");
350            }
351        }
352        _ => {}
353    }
354    Ok(payload)
355}
356
357impl ClipboardPayload {
358    /// Lower a decoded payload to the editor-facing `Pasted` outcome.
359    fn into_pasted(self) -> Pasted {
360        match self {
361            ClipboardPayload::Sprite {
362                w,
363                h,
364                pixels,
365                flags,
366            } => Pasted::Sprites {
367                rect: PixelRect {
368                    w: w as usize,
369                    h: h as usize,
370                    pixels,
371                },
372                flags: Some(flags),
373            },
374            ClipboardPayload::Sfx { slot, sfx } => Pasted::Sfx(SfxClip {
375                records: vec![Slotted {
376                    src: slot,
377                    value: sfx,
378                }],
379                patterns: Vec::new(),
380            }),
381            ClipboardPayload::Pattern { pattern, sfx } => Pasted::Sfx(SfxClip {
382                records: sfx
383                    .into_iter()
384                    .map(|(src, value)| Slotted { src, value })
385                    .collect(),
386                patterns: vec![pattern],
387            }),
388            ClipboardPayload::Map { w, h, tiles } => Pasted::Map {
389                w: w as usize,
390                h: h as usize,
391                tiles,
392            },
393        }
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::assets::{
401        Assets, CustomWave, MusicPattern, Note, Sfx, SpriteSheet, NOTE_CUSTOM_FLAG, SFX_COUNT,
402    };
403
404    #[test]
405    fn native_sprite_round_trips_with_flags() {
406        let payload = ClipboardPayload::Sprite {
407            w: 8,
408            h: 8,
409            pixels: (0..64).map(|i| (i % 16) as u8).collect(),
410            flags: vec![0b1100_0011],
411        };
412        let Pasted::Sprites { rect, flags } = parse(&encode(&payload)).unwrap() else {
413            panic!("not sprites")
414        };
415        assert_eq!((rect.w, rect.h), (8, 8));
416        assert_eq!(rect.pixels[1], 1);
417        assert_eq!(flags, Some(vec![0b1100_0011]));
418    }
419
420    #[test]
421    fn native_sfx_round_trips_custom_wave() {
422        let sfx = Sfx {
423            custom_wave: Some(CustomWave {
424                samples: [7; 32],
425                bass: true,
426            }),
427            ..Sfx::default()
428        };
429        let payload = ClipboardPayload::Sfx {
430            slot: 3,
431            sfx: sfx.clone(),
432        };
433        let Pasted::Sfx(clip) = parse(&encode(&payload)).unwrap() else {
434            panic!("not sfx")
435        };
436        assert_eq!(clip.records.len(), 1);
437        assert_eq!(clip.records[0].src, 3);
438        assert_eq!(clip.records[0].value, sfx); // custom_wave preserved.
439        assert!(clip.patterns.is_empty());
440    }
441
442    #[test]
443    fn native_pattern_round_trips_with_referenced_sfx() {
444        let pattern = MusicPattern {
445            channels: [Some(8), Some(9), None, None],
446            loop_back: false,
447            loop_start: false,
448            stop_at_end: false,
449        };
450        let payload = ClipboardPayload::Pattern {
451            pattern,
452            sfx: vec![(8, Sfx::default()), (9, Sfx::default())],
453        };
454        let Pasted::Sfx(clip) = parse(&encode(&payload)).unwrap() else {
455            panic!("not sfx")
456        };
457        assert_eq!(clip.records.len(), 2);
458        assert_eq!(clip.patterns, vec![pattern]);
459    }
460
461    #[test]
462    fn native_map_round_trips() {
463        let payload = ClipboardPayload::Map {
464            w: 3,
465            h: 2,
466            tiles: vec![1, 2, 3, 4, 5, 6],
467        };
468        let Pasted::Map { w, h, tiles } = parse(&encode(&payload)).unwrap() else {
469            panic!("not map")
470        };
471        assert_eq!((w, h), (3, 2));
472        assert_eq!(tiles, vec![1, 2, 3, 4, 5, 6]);
473    }
474
475    #[test]
476    fn native_blob_is_bare_json_marked_with_app() {
477        let blob = encode(&ClipboardPayload::Map {
478            w: 3,
479            h: 2,
480            tiles: vec![1, 2, 3, 4, 5, 6],
481        });
482        assert!(blob.starts_with(r#"{"app":"pixel8""#), "got {blob}");
483        assert!(!blob.contains("[pixel8]"), "the wrapper tag should be gone");
484        let Pasted::Map { w, h, tiles } = parse(&blob).unwrap() else {
485            panic!("not map")
486        };
487        assert_eq!((w, h, tiles), (3, 2, vec![1, 2, 3, 4, 5, 6]));
488    }
489
490    #[test]
491    fn native_map_encodes_with_kind_as_key() {
492        let blob = encode(&ClipboardPayload::Map {
493            w: 3,
494            h: 2,
495            tiles: vec![1, 2, 3, 4, 5, 6],
496        });
497        assert_eq!(
498            blob,
499            r#"{"app":"pixel8","version":1,"map":{"w":3,"h":2,"tiles":"010203040506"}}"#
500        );
501    }
502
503    #[test]
504    fn native_decode_rejects_bad_blobs_without_panicking() {
505        assert!(parse("{ not json }").is_err()); // starts with `{` but is not JSON.
506        assert!(parse("{}").is_err()); // missing app/version/kind.
507        assert!(parse(r#"{"hello":"world"}"#).is_err()); // valid JSON object, but not ours.
508                                                         // Right shape but unknown version.
509        assert!(parse(r#"{"app":"pixel8","version":9,"map":{"w":1,"h":1,"tiles":"00"}}"#).is_err());
510    }
511
512    #[test]
513    fn native_decode_rejects_short_sprite_pixels_without_panicking() {
514        // 8x8 sprite with an empty pixel vec — should error, not panic during paste.
515        let blob = encode(&ClipboardPayload::Sprite {
516            w: 8,
517            h: 8,
518            pixels: vec![],
519            flags: vec![],
520        });
521        assert!(parse(&blob).is_err());
522    }
523
524    #[test]
525    fn native_decode_rejects_short_map_tiles_without_panicking() {
526        // 4x4 map with an empty tile vec — should error, not panic during paste.
527        let blob = encode(&ClipboardPayload::Map {
528            w: 4,
529            h: 4,
530            tiles: vec![],
531        });
532        assert!(parse(&blob).is_err());
533    }
534
535    #[test]
536    fn native_decode_rejects_wrong_version() {
537        let text = r#"{"app":"pixel8","version":2,"map":{"w":1,"h":1,"tiles":"00"}}"#;
538        assert!(parse(text).is_err());
539    }
540
541    #[test]
542    fn parse_still_accepts_pico8_gfx() {
543        let Pasted::Sprites { rect, flags } = parse("[gfx]0202abcd[/gfx]").unwrap() else {
544            panic!("not sprites")
545        };
546        assert_eq!((rect.w, rect.h), (2, 2));
547        assert_eq!(flags, None); // PICO-8 carries no flags.
548    }
549
550    fn sfx_with_first_note(pitch: u8, vol: u8) -> Sfx {
551        let mut s = Sfx::default();
552        s.notes[0].pitch = pitch;
553        s.notes[0].volume = vol;
554        s
555    }
556
557    #[test]
558    fn paste_sprites_blits_and_clips() {
559        let mut sheet = SpriteSheet::default();
560        let rect = PixelRect {
561            w: 2,
562            h: 2,
563            pixels: vec![1, 2, 3, 4],
564        };
565        let r = paste_sprites(&mut sheet, &rect, 0, 0, None);
566        assert_eq!(sheet.get(0, 0), 1);
567        assert_eq!(sheet.get(1, 1), 4);
568        assert_eq!(r.clipped, 0);
569        // At the far corner only one pixel lands; three are clipped.
570        let r2 = paste_sprites(&mut sheet, &rect, 127, 127, None);
571        assert_eq!(sheet.get(127, 127), 1);
572        assert_eq!(r2.clipped, 3);
573    }
574
575    #[test]
576    fn paste_sfx_overwrites_from_selection_only() {
577        let mut sfx = vec![Sfx::default(); SFX_COUNT];
578        sfx[0] = sfx_with_first_note(40, 5); // pre-existing, must survive.
579        let records = vec![Slotted {
580            src: 8,
581            value: sfx_with_first_note(12, 3),
582        }];
583        let r = paste_sfx(&mut sfx, &records, 3);
584        assert_eq!(sfx[3].notes[0].pitch, 12);
585        assert_eq!(sfx[0].notes[0].volume, 5); // untouched.
586        assert!(r.summary.contains("SFX 3"));
587    }
588
589    #[test]
590    fn paste_sfx_remaps_custom_instrument_refs() {
591        // Record for src 5 has a note that plays src 4 as a custom instrument.
592        let mut inst_note = Sfx::default();
593        inst_note.notes[1] = Note {
594            pitch: 20,
595            wave: NOTE_CUSTOM_FLAG | 4,
596            volume: 5,
597            effect: 0,
598        };
599        let records = vec![
600            Slotted {
601                src: 4,
602                value: sfx_with_first_note(1, 1),
603            },
604            Slotted {
605                src: 5,
606                value: inst_note,
607            },
608        ];
609        let mut sfx = vec![Sfx::default(); SFX_COUNT];
610        paste_sfx(&mut sfx, &records, 2); // src 4 -> 2, src 5 -> 3.
611        assert_eq!(sfx[3].notes[1].wave, NOTE_CUSTOM_FLAG | 2);
612    }
613
614    #[test]
615    fn paste_sfx_clips_at_capacity() {
616        let mut sfx = vec![Sfx::default(); SFX_COUNT];
617        let records = vec![
618            Slotted {
619                src: 0,
620                value: sfx_with_first_note(1, 1),
621            },
622            Slotted {
623                src: 1,
624                value: sfx_with_first_note(2, 1),
625            },
626            Slotted {
627                src: 2,
628                value: sfx_with_first_note(3, 1),
629            },
630        ];
631        let r = paste_sfx(&mut sfx, &records, 62); // only 62, 63 fit.
632        assert_eq!(sfx[62].notes[0].pitch, 1);
633        assert_eq!(sfx[63].notes[0].pitch, 2);
634        assert_eq!(r.clipped, 1);
635        assert!(r.summary.contains("cut"));
636    }
637
638    #[test]
639    fn summaries_fit_the_status_bar() {
640        use crate::{fb::WIDTH, font::text_width};
641        // Worst cases: a wide slot range with everything clipped and many warns.
642        let budget = WIDTH - 2; // the bar's usable pixel width.
643        assert!(text_width(&seq_summary("SFX", 10, 54, 255, 2048)) <= budget);
644        assert!(text_width(&rect_summary(128, 128, 16384, 255)) <= budget);
645        assert!(text_width(&pattern_summary(63, 1, 64, 255, 2048)) <= budget);
646    }
647
648    #[test]
649    fn sprite_paste_summary_names_destination() {
650        let mut sheet = SpriteSheet::default();
651        let rect = PixelRect {
652            w: 8,
653            h: 8,
654            pixels: vec![1; 64],
655        };
656        // Sprite 1 lives at sheet pixel (8, 0).
657        let r = paste_sprites(&mut sheet, &rect, 8, 0, None);
658        assert_eq!(r.summary, "pasted 8x8 spr 1");
659    }
660
661    #[test]
662    fn paste_sprites_applies_flags_when_present() {
663        let mut sheet = SpriteSheet::default();
664        let rect = PixelRect {
665            w: 8,
666            h: 8,
667            pixels: vec![5; 64],
668        };
669        // Sprite 1 sits at sheet pixel (8, 0).
670        paste_sprites(&mut sheet, &rect, 8, 0, Some(&[0b1010_0101]));
671        assert_eq!(sheet.flags(1), 0b1010_0101);
672        // Without flags, the destination flag byte is left as-is.
673        sheet.set_flag(2, 0, true);
674        paste_sprites(&mut sheet, &rect, 16, 0, None);
675        assert_eq!(sheet.flags(2), 0b0000_0001);
676    }
677
678    #[test]
679    fn paste_pattern_appends_sfx_and_rewires_channels() {
680        let mut assets = Assets::default();
681        assets.sfx[10] = sfx_with_first_note(60, 5); // marks slot 10 used.
682        let clip = SfxClip {
683            records: vec![
684                Slotted {
685                    src: 8,
686                    value: sfx_with_first_note(1, 4),
687                },
688                Slotted {
689                    src: 9,
690                    value: sfx_with_first_note(2, 4),
691                },
692            ],
693            patterns: vec![MusicPattern {
694                channels: [Some(8), Some(9), None, None],
695                loop_back: false,
696                loop_start: false,
697                stop_at_end: false,
698            }],
699        };
700        let r = paste_pattern(&mut assets, &clip, 5);
701        // SFX appended after the last used slot (10) -> 11, 12; slot 10 intact.
702        assert_eq!(assets.sfx[11].notes[0].pitch, 1);
703        assert_eq!(assets.sfx[12].notes[0].pitch, 2);
704        assert_eq!(assets.sfx[10].notes[0].pitch, 60);
705        // The pattern lands at slot 5 with channels remapped to 11, 12.
706        assert_eq!(assets.music[5].channels, [Some(11), Some(12), None, None]);
707        assert!(r.warnings.is_empty());
708    }
709}