Quellcode durchsuchen

Merge branch 'm6-move-copy-swap' of bajdev/mpckit into main

bajdev vor 1 Tag
Ursprung
Commit
8834aa5d79

+ 8 - 0
dialogs.go

@@ -26,6 +26,14 @@ func (d *dialogs) OpenWAV() (string, error) {
 		PromptForSingleSelection())
 }
 
+func (d *dialogs) OpenProject() (string, error) {
+	return cancelled(d.a.Dialog.OpenFile().
+		SetTitle("Load a project").
+		CanChooseFiles(true).
+		AddFilter("MPC Sample projects", "*.xpj").
+		PromptForSingleSelection())
+}
+
 func (d *dialogs) ChooseFolder() (string, error) {
 	return cancelled(d.a.Dialog.OpenFile().
 		SetTitle("Export to folder").

+ 1 - 0
frontend/bindings/git.monoexp.com/bajdev/mpckit/internal/app/index.ts

@@ -10,6 +10,7 @@ export type {
     About,
     ExportResult,
     Limits,
+    LoadResult,
     Pad,
     Sequence,
     State

+ 10 - 0
frontend/bindings/git.monoexp.com/bajdev/mpckit/internal/app/models.ts

@@ -37,6 +37,16 @@ export interface Limits {
     "maxBars": number;
 }
 
+/**
+ * LoadResult is what Load returns. Path is empty if the dialog was
+ * cancelled. Warnings describe differences from the tested firmware.
+ */
+export interface LoadResult {
+    "path": string;
+    "state": State;
+    "warnings": string[] | null;
+}
+
 /**
  * Pad is one pad as the frontend shows it.
  */

+ 37 - 0
frontend/bindings/git.monoexp.com/bajdev/mpckit/internal/app/service.ts

@@ -58,6 +58,13 @@ export function ClearPad(slot: number): $CancellablePromise<$models.State> {
     return $Call.ByID(3159325479, slot);
 }
 
+/**
+ * CopyPad copies the pad in from to slot to, replacing what was there.
+ */
+export function CopyPad($from: number, to: number): $CancellablePromise<$models.State> {
+    return $Call.ByID(481175501, $from, to);
+}
+
 /**
  * ExportFolder writes the project into dir. If the project is already
  * there and overwrite is false, nothing is written and Exists lists it.
@@ -74,6 +81,29 @@ export function ExportZip(path: string, overwrite: boolean): $CancellablePromise
     return $Call.ByID(3485442350, path, overwrite);
 }
 
+/**
+ * Load asks for a project file and opens it in place of the current one.
+ * The frontend asks about unsaved changes first.
+ */
+export function Load(): $CancellablePromise<$models.LoadResult> {
+    return $Call.ByID(3449372205);
+}
+
+/**
+ * LoadPath opens the project at path in place of the current one.
+ */
+export function LoadPath(path: string): $CancellablePromise<$models.LoadResult> {
+    return $Call.ByID(1860148430, path);
+}
+
+/**
+ * MovePad moves the pad in from to slot to, replacing what was there.
+ * Recorded notes stay on their slots; the frontend warns first.
+ */
+export function MovePad($from: number, to: number): $CancellablePromise<$models.State> {
+    return $Call.ByID(2364397301, $from, to);
+}
+
 /**
  * New replaces the current project with a new, empty one.
  */
@@ -109,6 +139,13 @@ export function State(): $CancellablePromise<$models.State> {
     return $Call.ByID(2566861378);
 }
 
+/**
+ * SwapPads exchanges two pads.
+ */
+export function SwapPads(a: number, b: number): $CancellablePromise<$models.State> {
+    return $Call.ByID(1931051472, a, b);
+}
+
 /**
  * Version returns the app version.
  */

+ 17 - 6
frontend/src/App.svelte

@@ -6,9 +6,11 @@
   import BankBar from "./lib/BankBar.svelte";
   import ConfirmModal from "./lib/ConfirmModal.svelte";
   import PadPanel from "./lib/PadPanel.svelte";
+  import PadPicker from "./lib/PadPicker.svelte";
+  import { transfer, transferKind } from "./lib/pads";
   import SequencePanel from "./lib/SequencePanel.svelte";
   import SlotGrid, { type Cell } from "./lib/SlotGrid.svelte";
-  import { app, exportFolder, exportZip, listen, message, newProject, refresh, run, ui, type Tab } from "./lib/store.svelte";
+  import { app, exportFolder, exportZip, listen, loadProject, message, newProject, refresh, run, ui, type Tab } from "./lib/store.svelte";
   import { BANKS, PER_BANK, bars, gridSlots, tuneText } from "./lib/slots";
 
   onMount(() => {
@@ -38,13 +40,13 @@
     st
       ? gridSlots(ui.padBank).map((slot) => {
           const p = pads[slot];
-          const badges = [tuneText(p.coarse, p.fine), p.muteGroup ? "M" + p.muteGroup : ""].filter(Boolean);
+          const badges = [tuneText(p.coarse, p.fine), p.muteGroup ? "M" + p.muteGroup : "", p.hasNotes ? "notes" : ""].filter(Boolean);
           return {
             slot,
             label: p.label,
             name: p.name,
             meta: p.file ? badges.join(" · ") : "",
-            aria: `Pad ${p.label}, ${p.file || "empty"}`,
+            aria: `Pad ${p.label}, ${p.file || "empty"}${p.hasNotes ? ", has recorded notes" : ""}`,
           };
         })
       : [],
@@ -79,6 +81,11 @@
     }
   }
 
+  // A pad dropped on another: move, swap onto a filled pad, Alt to copy.
+  function dropPad(from: number, to: number, copy: boolean) {
+    transfer(transferKind(pads[from], pads[to], copy), from, to);
+  }
+
   async function rename(e: Event & { currentTarget: HTMLInputElement }) {
     const input = e.currentTarget;
     const name = input.value;
@@ -109,7 +116,7 @@
     </div>
     <div class="actions">
       <button type="button" class="btn" onclick={newProject}>New</button>
-      <button type="button" class="btn" disabled>Load</button>
+      <button type="button" class="btn" onclick={loadProject}>Load</button>
       <button type="button" class="btn" onclick={exportZip}>Export zip</button>
       <button type="button" class="btn primary" onclick={exportFolder}>Export to folder</button>
     </div>
@@ -144,9 +151,10 @@
               ui.padBank = b;
               ui.padSlot = b * PER_BANK;
             }}
+            ondragbank={(b) => (ui.padBank = b)}
           />
-          <SlotGrid cells={padCells} selected={ui.padSlot} onpick={pickPad} dropTarget />
-          <p class="hint grid-hint">Click a pad to select and hear it. Drop a WAV file on any pad to assign it. (Dragging pads to move, swap and copy them comes in M6.)</p>
+          <SlotGrid cells={padCells} selected={ui.padSlot} onpick={pickPad} dropTarget ondroppad={dropPad} />
+          <p class="hint grid-hint">Click a pad to select and hear it. Drag a pad to move it, drag onto a filled pad to swap, hold Alt while dragging to copy. Drag over a bank letter to reach another bank. Drop a WAV file on any pad to assign it.</p>
         </section>
         <PadPanel pad={pads[ui.padSlot]} limits={st.limits} />
       </div>
@@ -187,6 +195,9 @@
   {#if ui.aboutOpen}
     <AboutModal onclose={() => (ui.aboutOpen = false)} />
   {/if}
+  {#if ui.picker && st}
+    <PadPicker picker={ui.picker} pads={pads} onclose={() => (ui.picker = null)} />
+  {/if}
   {#if ui.confirm}
     <ConfirmModal c={ui.confirm} />
   {/if}

+ 11 - 1
frontend/src/lib/BankBar.svelte

@@ -1,4 +1,5 @@
 <script lang="ts">
+  import { isPadDrag } from "./drag";
   import { BANKS, PER_BANK } from "./slots";
 
   let {
@@ -6,11 +7,13 @@
     counts,
     what,
     onpick,
+    ondragbank,
   }: {
     bank: number;
     counts: number[]; // filled slots per bank
     what: string; // "pads filled", "slots used"
     onpick: (bank: number) => void;
+    ondragbank?: (bank: number) => void; // a pad dragged over a bank button
   } = $props();
 </script>
 
@@ -21,7 +24,14 @@
       class:on={i === bank}
       aria-pressed={i === bank}
       aria-label="Bank {letter}, {counts[i]} of {PER_BANK} {what}"
-      onclick={() => onpick(i)}>{letter}</button
+      onclick={() => onpick(i)}
+      ondragover={(e) => {
+        // Dragging a pad over a bank shows that bank, to drop on its pads.
+        if (!ondragbank || !isPadDrag(e)) return;
+        e.preventDefault();
+        if (e.dataTransfer) e.dataTransfer.dropEffect = "none";
+        if (i !== bank) ondragbank(i);
+      }}>{letter}</button
     >
   {/each}
 </div>

+ 9 - 4
frontend/src/lib/PadPanel.svelte

@@ -1,7 +1,7 @@
 <script lang="ts">
   import { Service, type Limits, type Pad } from "./api";
   import { play } from "./audio";
-  import { app, message, run } from "./store.svelte";
+  import { app, message, run, ui } from "./store.svelte";
   import { signed } from "./slots";
 
   // Settings for the selected pad.
@@ -120,12 +120,14 @@
       </div>
 
       <div class="row divided">
-        <button type="button" class="btn secondary" disabled>Move to pad</button>
-        <button type="button" class="btn secondary" disabled>Copy to pad</button>
+        <button type="button" class="btn secondary" onclick={() => (ui.picker = { kind: "move", from: pad.slot })}>Move to pad</button>
+        <button type="button" class="btn secondary" onclick={() => (ui.picker = { kind: "copy", from: pad.slot })}>Copy to pad</button>
       </div>
+      {#if pad.hasNotes}
+        <p class="hint notes">Recorded notes in a sequence play this pad. They stay on {pad.label} if the sample moves.</p>
+      {/if}
     </div>
   {/if}
-  <p class="pending">Move and copy are not connected yet (milestone M6).</p>
 </section>
 
 <style>
@@ -138,6 +140,9 @@
     grid-template-columns: repeat(2, minmax(0, 1fr));
     gap: 16px;
   }
+  .notes {
+    color: var(--accent);
+  }
   .drop:global(.file-drop-target-active) {
     border-color: var(--accent);
     background: rgba(245, 165, 36, 0.08);

+ 137 - 0
frontend/src/lib/PadPicker.svelte

@@ -0,0 +1,137 @@
+<script lang="ts">
+  import type { Pad } from "./api";
+  import { transfer, transferKind } from "./pads";
+  import { BANKS, PER_BANK } from "./slots";
+  import type { Picker } from "./store.svelte";
+
+  // "Move to pad" / "Copy to pad": the keyboard-friendly route to what
+  // dragging does.
+  let { picker, pads, onclose }: { picker: Picker; pads: Pad[]; onclose: () => void } = $props();
+
+  const src = $derived(pads[picker.from]);
+
+  // Start on the first empty pad after the source, else the next pad.
+  function firstTarget(): number {
+    for (let i = 1; i < pads.length; i++) {
+      const s = (picker.from + i) % pads.length;
+      if (!pads[s].file) return s;
+    }
+    return (picker.from + 1) % pads.length;
+  }
+  const start = firstTarget();
+  let bank = $state(Math.floor(start / PER_BANK));
+  let pad = $state(start % PER_BANK);
+
+  const target = $derived(bank * PER_BANK + pad);
+  const kind = $derived(transferKind(src, pads[target], picker.kind === "copy"));
+  const action = $derived(
+    target === picker.from ? "" : kind === "move" ? "Move" : kind === "swap" ? "Swap" : pads[target].file ? "Copy and replace" : "Copy",
+  );
+
+  let firstField: HTMLSelectElement | undefined = $state();
+  $effect(() => {
+    firstField?.focus();
+  });
+
+  async function go() {
+    if (!action) return;
+    // Read everything before closing: closing clears ui.picker, and props
+    // are live, so `picker` is null from then on.
+    const what = kind;
+    const from = picker.from;
+    const to = target;
+    onclose();
+    await transfer(what, from, to);
+  }
+
+  function keydown(e: KeyboardEvent) {
+    if (e.key === "Escape") onclose();
+  }
+</script>
+
+<svelte:window onkeydown={keydown} />
+
+<div class="backdrop">
+  <div class="dialog" role="dialog" aria-modal="true" aria-labelledby="picker-title">
+    <h2 id="picker-title">{picker.kind === "move" ? "Move" : "Copy"} {src.label} to pad</h2>
+    <p class="from">{src.name}</p>
+    <div class="fields">
+      <div class="control">
+        <label class="label" for="picker-bank">Bank</label>
+        <select id="picker-bank" class="field" bind:value={bank} bind:this={firstField}>
+          {#each BANKS.split("") as letter, i (letter)}
+            <option value={i}>{letter}</option>
+          {/each}
+        </select>
+      </div>
+      <div class="control">
+        <label class="label" for="picker-pad">Pad</label>
+        <select id="picker-pad" class="field" bind:value={pad}>
+          {#each Array.from({ length: PER_BANK }, (_, i) => i) as i (i)}
+            {@const p = pads[bank * PER_BANK + i]}
+            <option value={i}>{p.label}{p.file ? " · " + p.name : " · empty"}</option>
+          {/each}
+        </select>
+      </div>
+    </div>
+    <p class="hint">
+      {#if !action}
+        Choose a different pad.
+      {:else if kind === "swap"}
+        {pads[target].label} has a sample: the two pads will swap.
+      {:else if kind === "copy" && pads[target].file}
+        {pads[target].label} will be replaced.
+      {:else}
+        &nbsp;
+      {/if}
+    </p>
+    <div class="buttons">
+      <button type="button" class="btn secondary" onclick={onclose}>Cancel</button>
+      <button type="button" class="btn primary" disabled={!action} onclick={go}>{action || (picker.kind === "move" ? "Move" : "Copy")}</button>
+    </div>
+  </div>
+</div>
+
+<style>
+  .backdrop {
+    position: fixed;
+    inset: 0;
+    padding: 24px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: rgba(10, 11, 13, 0.78);
+  }
+  .dialog {
+    width: 100%;
+    max-width: 440px;
+    padding: 24px;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    background: var(--surface);
+    border: 1px solid var(--edge);
+    border-radius: 12px;
+  }
+  h2 {
+    margin: 0;
+    font-family: var(--display);
+    font-size: 20px;
+    font-weight: 700;
+  }
+  .from {
+    margin: 0;
+    color: var(--soft);
+    overflow-wrap: anywhere;
+  }
+  .fields {
+    display: grid;
+    grid-template-columns: 1fr 2fr;
+    gap: 10px;
+  }
+  .buttons {
+    display: flex;
+    justify-content: flex-end;
+    gap: 8px;
+  }
+</style>

+ 48 - 0
frontend/src/lib/SlotGrid.svelte

@@ -9,18 +9,42 @@
 </script>
 
 <script lang="ts">
+  import { draggedSlot, endDrag, isPadDrag, startDrag } from "./drag";
+
   // A 4x4 grid of pads or sequence slots, used by both tabs.
   let {
     cells,
     selected,
     onpick,
     dropTarget = false,
+    ondroppad,
   }: {
     cells: Cell[]; // 16 cells in grid order, top-left first
     selected: number;
     onpick: (slot: number) => void;
     dropTarget?: boolean; // accept WAV files dropped from the system
+    ondroppad?: (from: number, to: number, copy: boolean) => void; // filled cells can be dragged onto others
   } = $props();
+
+  let over: number | null = $state(null); // cell a pad is dragged over
+  let copying = $state(false); // Alt held during the drag
+
+  function dragover(e: DragEvent, slot: number) {
+    if (!ondroppad || !isPadDrag(e)) return;
+    e.preventDefault(); // allow the drop
+    copying = e.altKey;
+    if (e.dataTransfer) e.dataTransfer.dropEffect = e.altKey ? "copy" : "move";
+    over = slot === draggedSlot() ? null : slot;
+  }
+
+  function drop(e: DragEvent, slot: number) {
+    if (!ondroppad || !isPadDrag(e)) return;
+    e.preventDefault();
+    const from = draggedSlot();
+    over = null;
+    endDrag();
+    if (from !== null && from !== slot) ondroppad(from, slot, e.altKey);
+  }
 </script>
 
 <div class="grid">
@@ -29,11 +53,24 @@
       type="button"
       class:filled={c.name !== ""}
       class:on={c.slot === selected}
+      class:over={c.slot === over}
+      class:copying={c.slot === over && copying}
       aria-pressed={c.slot === selected}
       aria-label={c.aria}
       data-file-drop-target={dropTarget ? "" : undefined}
       data-slot={c.slot}
       onclick={() => onpick(c.slot)}
+      draggable={!!ondroppad && c.name !== ""}
+      ondragstart={(e) => startDrag(e, c.slot)}
+      ondragend={() => {
+        over = null;
+        endDrag();
+      }}
+      ondragover={(e) => dragover(e, c.slot)}
+      ondragleave={() => {
+        if (over === c.slot) over = null;
+      }}
+      ondrop={(e) => drop(e, c.slot)}
     >
       <span class="num">{c.label}</span>
       <span class="name">{c.name}</span>
@@ -76,6 +113,17 @@
   button.on {
     border-color: var(--accent);
   }
+  /* A pad dragged over this cell: dashed for move or swap, solid for copy. */
+  button.over {
+    border-style: dashed;
+    border-color: var(--accent);
+  }
+  button.over.copying {
+    border-style: solid;
+  }
+  button[draggable="true"] {
+    cursor: grab;
+  }
   /* Wails adds this class while a file is dragged over a drop target. */
   button:global(.file-drop-target-active) {
     border-color: var(--accent);

+ 1 - 0
frontend/src/lib/api.ts

@@ -3,6 +3,7 @@ export { Service } from "../../bindings/git.monoexp.com/bajdev/mpckit/internal/a
 export type {
   About,
   ExportResult,
+  LoadResult,
   Limits,
   Pad,
   Sequence,

+ 28 - 0
frontend/src/lib/drag.ts

@@ -0,0 +1,28 @@
+// Dragging pads inside the page. Files dragged in from the system are
+// handled by Wails (data-file-drop-target), which ignores drags without
+// files, so the two do not meet.
+
+export const PAD_TYPE = "application/x-mpckit-pad";
+
+// The slot being dragged. Kept here because dataTransfer's data cannot be
+// read during dragover, only its types.
+let dragging: number | null = null;
+
+export function startDrag(e: DragEvent, slot: number) {
+  dragging = slot;
+  e.dataTransfer?.setData(PAD_TYPE, String(slot));
+  if (e.dataTransfer) e.dataTransfer.effectAllowed = "copyMove";
+}
+
+export function endDrag() {
+  dragging = null;
+}
+
+/** True while a pad (not a file) is being dragged over the page. */
+export function isPadDrag(e: DragEvent): boolean {
+  return dragging !== null && !!e.dataTransfer?.types.includes(PAD_TYPE);
+}
+
+export function draggedSlot(): number | null {
+  return dragging;
+}

+ 78 - 0
frontend/src/lib/pads.ts

@@ -0,0 +1,78 @@
+// Moving, copying and swapping pads (build plan M6), shared by drag and drop
+// and the "Move to pad" / "Copy to pad" buttons.
+import { Service, type Pad } from "./api";
+import { app, confirm, message, run, ui } from "./store.svelte";
+import { PER_BANK } from "./slots";
+
+export type Transfer = "move" | "swap" | "copy";
+
+/** What dropping or sending pad `from` onto `to` does. */
+export function transferKind(from: Pad, to: Pad, copy: boolean): Transfer {
+  if (copy) return "copy";
+  return to.file ? "swap" : "move";
+}
+
+/**
+ * Moves, swaps or copies a pad, asking first when that would replace a
+ * sample or change what recorded notes play. Recorded notes stay on their
+ * slots (format doc section 3): a note keeps pointing at the slot, not the
+ * sample.
+ */
+export async function transfer(kind: Transfer, from: number, to: number): Promise<void> {
+  try {
+    await doTransfer(kind, from, to);
+  } catch (e) {
+    app.error = message(e);
+  }
+}
+
+async function doTransfer(kind: Transfer, from: number, to: number): Promise<void> {
+  const pads = app.state?.pads;
+  if (!pads || from === to) return;
+  const src = pads[from];
+  const dst = pads[to];
+
+  const items: string[] = [];
+  if (kind === "copy" && dst.file) {
+    items.push(`${dst.label} (${dst.name}) will be replaced.`);
+  }
+  // Pads whose sound changes: both for move and swap, the target for copy.
+  const changed = kind === "copy" ? [dst] : [src, dst];
+  for (const p of changed) {
+    if (p.hasNotes) {
+      items.push(`${p.label} has recorded notes. They stay on ${p.label} and will play ${soundAfter(kind, p, src, dst)}.`);
+    }
+  }
+  if (items.length) {
+    const ok = await confirm({
+      title: `${verb(kind)} ${src.label} ${kind === "swap" ? "and" : "to"} ${dst.label}?`,
+      message: "Please check:",
+      items,
+      action: verb(kind),
+    });
+    if (!ok) return;
+  }
+
+  const call =
+    kind === "move" ? () => Service.MovePad(from, to) : kind === "swap" ? () => Service.SwapPads(from, to) : () => Service.CopyPad(from, to);
+  if (await run(call)) {
+    ui.padBank = Math.floor(to / PER_BANK);
+    ui.padSlot = to;
+  }
+}
+
+function verb(kind: Transfer): string {
+  return kind === "move" ? "Move" : kind === "swap" ? "Swap" : "Copy";
+}
+
+// What a pad's notes will play once the transfer is done.
+function soundAfter(kind: Transfer, p: Pad, src: Pad, dst: Pad): string {
+  if (p.slot === src.slot) {
+    return kind === "swap" ? describe(dst) : "nothing (the pad will be empty)";
+  }
+  return describe(src);
+}
+
+function describe(p: Pad): string {
+  return p.file ? `"${p.name}"` : "nothing (the pad will be empty)";
+}

+ 38 - 2
frontend/src/lib/store.svelte.ts

@@ -21,6 +21,9 @@ export type Confirm = {
   resolve: (ok: boolean) => void;
 };
 
+/** Open "Move to pad" / "Copy to pad" picker. */
+export type Picker = { kind: "move" | "copy"; from: number };
+
 export const ui = $state({
   tab: "pads" as Tab,
   padBank: 0,
@@ -29,6 +32,7 @@ export const ui = $state({
   seqSlot: 0,
   aboutOpen: false,
   confirm: null as Confirm | null,
+  picker: null as Picker | null,
 });
 
 export function message(e: unknown): string {
@@ -60,11 +64,43 @@ export function refresh(): Promise<boolean> {
   return run(() => Service.State());
 }
 
+/** Asks before throwing away unsaved changes. True means go ahead. */
+async function discardOK(): Promise<boolean> {
+  if (!app.state?.dirty) return true;
+  return confirm({
+    title: "Discard unsaved changes?",
+    message: "This project has changes that have not been exported. They will be lost.",
+    items: [],
+    action: "Discard",
+  });
+}
+
+function resetSelection() {
+  ui.padBank = ui.padSlot = ui.seqBank = ui.seqSlot = 0;
+}
+
 /** Starts a new, empty project. */
-export function newProject(): Promise<boolean> {
+export async function newProject(): Promise<void> {
+  if (!(await discardOK())) return;
   stopAll();
   app.notice = "";
-  return run(() => Service.New());
+  if (await run(() => Service.New())) resetSelection();
+}
+
+/** Asks for a project file and opens it. */
+export async function loadProject(): Promise<void> {
+  if (!(await discardOK())) return;
+  try {
+    const res = await Service.Load();
+    if (!res.path) return; // dialog cancelled
+    stopAll();
+    app.state = res.state;
+    resetSelection();
+    app.error = res.warnings?.length ? "Loaded with warnings:\n" + res.warnings.join("\n") : "";
+    app.notice = "Loaded " + res.path;
+  } catch (e) {
+    app.error = message(e);
+  }
 }
 
 /** Listens for state and errors Go sends on its own (e.g. after a file drop). */

+ 50 - 0
internal/app/app.go

@@ -85,6 +85,14 @@ type About struct {
 	Repo    string `json:"repo"`
 }
 
+// LoadResult is what Load returns. Path is empty if the dialog was
+// cancelled. Warnings describe differences from the tested firmware.
+type LoadResult struct {
+	Path     string   `json:"path"`
+	State    State    `json:"state"`
+	Warnings []string `json:"warnings"`
+}
+
 // ExportResult is what an export call returns. If Exists is not empty,
 // nothing was written: the frontend asks the user, then calls again with
 // overwrite set.
@@ -97,6 +105,7 @@ type ExportResult struct {
 // cancels. main.go implements them with Wails; tests use a fake.
 type Dialogs interface {
 	OpenWAV() (string, error)
+	OpenProject() (string, error)
 	ChooseFolder() (string, error)
 	SaveZip(defaultName string) (string, error)
 }
@@ -298,3 +307,44 @@ func (s *Service) exported(path string, err error) (ExportResult, error) {
 	s.p.Dirty = false
 	return ExportResult{Path: path}, nil
 }
+
+// MovePad moves the pad in from to slot to, replacing what was there.
+// Recorded notes stay on their slots; the frontend warns first.
+func (s *Service) MovePad(from, to int) (State, error) {
+	return s.edit(func(p *project.Project) error { return p.MovePad(from, to) })
+}
+
+// CopyPad copies the pad in from to slot to, replacing what was there.
+func (s *Service) CopyPad(from, to int) (State, error) {
+	return s.edit(func(p *project.Project) error { return p.CopyPad(from, to) })
+}
+
+// SwapPads exchanges two pads.
+func (s *Service) SwapPads(a, b int) (State, error) {
+	return s.edit(func(p *project.Project) error { return p.SwapPads(a, b) })
+}
+
+// Load asks for a project file and opens it in place of the current one.
+// The frontend asks about unsaved changes first.
+func (s *Service) Load() (LoadResult, error) {
+	path, err := s.dialogs.OpenProject()
+	if err != nil || path == "" {
+		return LoadResult{State: s.State()}, err
+	}
+	return s.LoadPath(path)
+}
+
+// LoadPath opens the project at path in place of the current one.
+func (s *Service) LoadPath(path string) (LoadResult, error) {
+	p, warnings, err := project.Load(path)
+	if err != nil {
+		return LoadResult{State: s.State()}, fmt.Errorf("%s: %w", filepath.Base(path), err)
+	}
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.p = p
+	if warnings == nil {
+		warnings = []string{}
+	}
+	return LoadResult{Path: path, State: s.state(), Warnings: warnings}, nil
+}

+ 74 - 2
internal/app/app_test.go

@@ -21,11 +21,12 @@ const testdata = "../../testdata"
 
 // fakeDialogs returns preset answers; "" is a cancelled dialog.
 type fakeDialogs struct {
-	wav, folder, zip string
-	zipDefault       string
+	wav, project, folder, zip string
+	zipDefault                string
 }
 
 func (f *fakeDialogs) OpenWAV() (string, error)      { return f.wav, nil }
+func (f *fakeDialogs) OpenProject() (string, error)  { return f.project, nil }
 func (f *fakeDialogs) ChooseFolder() (string, error) { return f.folder, nil }
 func (f *fakeDialogs) SaveZip(name string) (string, error) {
 	f.zipDefault = name
@@ -302,3 +303,74 @@ func TestAudio(t *testing.T) {
 		}
 	}
 }
+
+func TestMoveCopySwap(t *testing.T) {
+	s, _ := newService()
+	kick := filepath.Join(kickDir, "Linn 9000 kick.wav")
+	kick2 := filepath.Join(kickDir, "Linn 9000 kick001.wav")
+	_, err := s.AssignSamplePath(0, kick)
+	must(t, err)
+	_, err = s.SetTune(0, 3, 0)
+	must(t, err)
+	_, err = s.AssignSamplePath(1, kick2)
+	must(t, err)
+
+	// Copy across banks keeps the tuning and shares the sample.
+	st, err := s.CopyPad(0, 40)
+	must(t, err)
+	if st.Pads[40].File != "Linn 9000 kick.wav" || st.Pads[40].Coarse != 3 || st.SampleCount != 2 {
+		t.Errorf("copy: %+v, %d samples", st.Pads[40], st.SampleCount)
+	}
+	// Swap.
+	st, err = s.SwapPads(1, 40)
+	must(t, err)
+	if st.Pads[1].File != "Linn 9000 kick.wav" || st.Pads[40].File != "Linn 9000 kick001.wav" {
+		t.Error("swap")
+	}
+	// Move onto a filled pad replaces it.
+	st, err = s.MovePad(40, 0)
+	must(t, err)
+	if st.Pads[40].File != "" || st.Pads[0].File != "Linn 9000 kick001.wav" || st.Pads[0].Coarse != 0 {
+		t.Errorf("move: A01 %+v", st.Pads[0])
+	}
+	if _, err := s.MovePad(0, 128); err == nil {
+		t.Error("move to slot 128 accepted")
+	}
+}
+
+func TestLoad(t *testing.T) {
+	s, d := newService()
+
+	// Cancelled: nothing changes.
+	res, err := s.Load()
+	must(t, err)
+	if res.Path != "" || res.State.Name != DefaultName {
+		t.Errorf("cancelled load: %+v", res.Path)
+	}
+
+	d.project = filepath.Join(testdata, "device", "DemoFile.xpj")
+	res, err = s.Load()
+	must(t, err)
+	st := res.State
+	if st.Name != "DemoFile" || st.Dirty || st.SampleCount != 6 || len(res.Warnings) != 0 || res.Path != d.project {
+		t.Errorf("load: %q dirty %v samples %d warnings %v", st.Name, st.Dirty, st.SampleCount, res.Warnings)
+	}
+	// DemoFile has notes on A01-A04 only.
+	for i, p := range st.Pads {
+		if p.HasNotes != (i < 4) {
+			t.Errorf("%s hasNotes %v", p.Label, p.HasNotes)
+		}
+	}
+	if !strings.HasPrefix(st.Pads[0].Audio, "/audio/0?k=") || st.Pads[0].End != 44099 {
+		t.Errorf("A01 %+v", st.Pads[0])
+	}
+	if st.SequenceCount != 2 || st.Sequences[0].Name != "Intro" || !st.Sequences[0].HasNotes {
+		t.Errorf("sequences %+v", st.Sequences)
+	}
+
+	// A bad file keeps the current project.
+	_, err = s.LoadPath(filepath.Join(testdata, "device", "DemoFile_[ProjectData]", "Bass-Pure C.wav"))
+	if err == nil || s.State().Name != "DemoFile" {
+		t.Errorf("bad load: %v", err)
+	}
+}

+ 39 - 0
pull-requests/m6-move-copy-swap/m6-move-copy-swap.md

@@ -0,0 +1,39 @@
+# M6: move, copy, swap
+
+Branch: `m6-move-copy-swap` → `main`
+
+## What changed
+
+**Pads can be moved, copied and swapped**, across banks:
+- **Dragging** a filled pad onto an empty pad moves it; onto a filled pad swaps the two; holding **Alt** copies (onto a filled pad: copy and replace). The target cell shows a dashed outline for move/swap and a solid one for copy.
+- **Dragging over a bank letter** switches the grid to that bank, so a pad can be dropped on another bank's pads.
+- **Move to pad / Copy to pad** buttons open a picker (bank and pad, with each pad's sample shown): the keyboard route to the same three actions. Move onto a filled pad becomes Swap, as with dragging.
+- Dragging pads inside the window doesn't interfere with dropping WAV files from Explorer: the Wails runtime only handles drags that carry files.
+
+**Recorded-notes warning** (build plan M6, format doc section 3). Notes point at a slot, not a sample, so they stay where they are. Before any move, swap or copy that changes what a pad with notes will play, a dialog lists each such pad and what its notes will play afterwards. Pads without notes get no dialog. Copying onto a filled pad also asks first. Pads with notes show a `notes` badge in the grid and a note in the settings panel.
+
+**Load (brought forward from M8).** M6's done-when needs a project with notes, and only the device makes those, so the app needs Load now. Load opens a `.xpj` (the name comes from the file, samples from its `_[ProjectData]` folder) and reports firmware or version warnings. New and Load ask before discarding unsaved changes. M8 keeps missing-file badges, the window-close prompt and the other guard rails.
+
+**Go service**: `MovePad`, `CopyPad`, `SwapPads`, `Load` (dialog) and `LoadPath`. The `Dialogs` interface gains `OpenProject`.
+
+**Fix after the first laptop test:** the picker's Move/Copy button did nothing (step 7). The click handler closed the dialog before reading the dialog's choice. Closing clears `ui.picker`, and Svelte 5 props are live, so the following `picker.from` threw inside an async handler, which nothing reported. The handler now reads the choice first. `transfer` also shows any unexpected error in the banner instead of failing silently.
+
+## Testing
+
+On the server: `go vet` and `go test ./...` pass. New service tests cover move/copy/swap across banks (tuning carried, sample shared, move onto a filled pad, out-of-range slot), and Load: cancelled dialog, DemoFile (name, 6 samples, notes on A01–A04 only, preview URL, 2 sequences), and a bad file keeping the current project.
+
+bajdev's laptop run (2026-11-11): steps 1–6 and 8 passed; step 7 failed and is fixed above. Re-run step 7. The frontend is untested here (no Node). **On the laptop** (create `C:\temp\m6` first):
+
+1. `npm run check` in `frontend`: 0 errors, 0 warnings.
+2. `wails3 dev`, **Load** `testdata\device\DemoFile.xpj`. A01–A04 show `notes`; B01 and B02 don't. Pads play when clicked.
+3. **Move across banks, with a warning:** drag A01 over the **C** bank letter (the grid switches to bank C) and drop on C01. A dialog says A01 has notes that will play nothing. Cancel: nothing changes. Do it again and confirm: C01 has the kick, A01 is empty.
+4. **No warning without notes:** **Alt**-drag B01 to bank D, pad D05. It copies with no dialog; B01 is unchanged.
+5. **Swap:** drag A02 onto B02. A dialog lists A02's notes; confirm. A02 and B02 have swapped.
+6. Rename to `UiMoves`, **Export to folder** `C:\temp\m6`. Then:
+   ```
+   go run ./cmd/mpckit-cli -load testdata\device\DemoFile.xpj -name CliMoves -out C:\temp\m6 -move "A1:C1" -copy "B1:D5" -swap "A2:B2"
+   go run ./tools/xpjdiff C:\temp\m6\UiMoves.xpj C:\temp\m6\CliMoves.xpj
+   ```
+   prints `identical`. **(M6 done-when: all three across banks.)**
+7. **Buttons:** select C01, **Copy to pad** → bank A, pad A03 (shows the hat): the button reads "Copy and replace". The warning lists A03's notes. **Move to pad** onto an empty pad moves it.
+8. **Unsaved changes:** after any edit, **New** asks to discard; Cancel keeps the project.