From a6d148ca4c11eabda85fed2af73f47af362654ae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 08:43:59 +0000 Subject: [PATCH 1/3] Add a context-aware right-click menu and Cut/Copy/Paste The score canvas's right-click menu previously only had "Move playback marker here". It's now context-aware: it hit-tests whatever's under the cursor (reusing the same ScoreHitType-based hit test that already drives left-click selection) and shows a different set of commands depending on whether you right-clicked a note, an empty gap, a tie, a chord marker, lyric text, or empty canvas -- syncing the selection to the right-click target first (without triggering a click handler's side effects, like deleting a chord marker or opening an inline editor) so the menu always acts on what you clicked, and so an existing multi-note selection isn't collapsed when you right-click inside it. Nearly every item on the menu reuses a command that already existed (wired to the ribbon/menu elsewhere) -- Shorten/Extend, octave/transpose, Split/Merge, Tie, Ornaments, Delete, Add/Duplicate Measure, Add Chord Marker. Cut/Copy/Paste are new: there was no clipboard concept anywhere in the app before this. - New `INoteClipboardService`/`NoteClipboardService`: a small in-app clipboard for melody notes (not the OS clipboard), registered as a singleton like `IMidiOutput` so cut/copy in one tab can be pasted into another. - `NoteEditorViewModel` gains `CopySelectedNotes()` (returns false if nothing was selected, so Cut knows not to fall through to Delete's own "nothing selected" behavior) and `PasteNotes()`, which inserts after the last selected note, at the selected gap, or at the end of the current measure -- wrapped in the same undo-snapshot command pattern already used for Split/Merge. - `ScoreCanvas` exposes a new `ContextMenuOpening` event, firing after the canvas has already synced the selection to the hit-test target; it still owns "Move playback marker here" itself, but the edit-command items come entirely from the event handler. - `MainForm` builds the context menu's items per hit type, and adds Cut/Copy/Paste to the Edit menu and Ctrl+X/C/V (guarded the same way Delete already is, so a focused text box gets native cut/copy/paste instead). Verified via the same sandbox pipeline as every prior change this session: dotnet build and dotnet format --verify-no-changes both pass, and the full regression suite passes under Mono + Xvfb against the rebuilt assembly -- including three new integration tests added to the existing MainFormHarness.cs covering copy+paste (clone inserted at the right position), cut (removes the note and populates the clipboard), and cut with nothing selected (must not fall through to deleting the last note in the measure). The context menu's actual on-screen appearance hasn't been visually confirmed on a real Windows machine, same caveat as every prior UI change this session. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Pguj4XSScE141p1ScWoqEr --- JianpuEditor/AppBootstrapper.cs | 2 + JianpuEditor/Controls/ScoreCanvas.cs | 126 +++++++++++++- .../Abstractions/INoteClipboardService.cs | 23 +++ JianpuEditor/MainForm.cs | 159 ++++++++++++++++++ JianpuEditor/Services/NoteClipboardService.cs | 52 ++++++ .../ViewModels/NoteEditorViewModel.cs | 119 ++++++++++++- README.md | 7 + 7 files changed, 478 insertions(+), 10 deletions(-) create mode 100644 JianpuEditor/Core/Abstractions/INoteClipboardService.cs create mode 100644 JianpuEditor/Services/NoteClipboardService.cs diff --git a/JianpuEditor/AppBootstrapper.cs b/JianpuEditor/AppBootstrapper.cs index ae6a1bd..8c6454e 100644 --- a/JianpuEditor/AppBootstrapper.cs +++ b/JianpuEditor/AppBootstrapper.cs @@ -58,6 +58,8 @@ public static ServiceProvider ConfigureServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + // Shared across tabs deliberately (like IMidiOutput): cut/copy in one tab, paste in another. + services.AddSingleton(); services.AddTransient(); return services.BuildServiceProvider(); diff --git a/JianpuEditor/Controls/ScoreCanvas.cs b/JianpuEditor/Controls/ScoreCanvas.cs index a7dda7b..9ff8d8a 100644 --- a/JianpuEditor/Controls/ScoreCanvas.cs +++ b/JianpuEditor/Controls/ScoreCanvas.cs @@ -50,6 +50,24 @@ public bool HasChordSelected public IReadOnlyList SelectedNotes { get; set; } = Array.Empty(); } + /// Raised while the canvas's right-click menu is being built, after the canvas has + /// already synced the selection to whatever's under the cursor (see + /// ). A handler adds its own items + /// to based on ; the canvas contributes only its own + /// intrinsic items (currently just "Move playback marker here"). + public sealed class ScoreContextMenuEventArgs : EventArgs + { + public ScoreContextMenuEventArgs(ContextMenuStrip menu, ScoreHitType hitType) + { + Menu = menu; + HitType = hitType; + } + + public ContextMenuStrip Menu { get; } + + public ScoreHitType HitType { get; } + } + public sealed class ScoreCanvas : Panel { private readonly JianpuRenderer _renderer = new JianpuRenderer(); @@ -117,7 +135,6 @@ public ScoreCanvas() AppTheme.ThemeChanged += OnThemeChanged; _contentContextMenu = new ContextMenuStrip(); - _contentContextMenu.Items.Add("Move playback marker here", null, OnMovePlaybackMarkerHereClicked); _contentContextMenu.Opening += OnContentContextMenuOpening; _contentPanel.ContextMenuStrip = _contentContextMenu; } @@ -205,6 +222,8 @@ public void ApplyTheme() public event Action PlaybackSeeked; + public event EventHandler ContextMenuOpening; + public JianpuScore Score { get { return _score; } @@ -814,21 +833,110 @@ private void CommitPlaybackHeadDrag(Point logicalLocation) } /// - /// Right-click alternative to dragging the playback marker. Dragging can only *start* by - /// grabbing the marker's current (narrow) on-screen position, so on a long score the - /// marker effectively feels pinned wherever it last was (typically the very first bar, - /// since that's where every load/new/reset leaves it) until the user finds that exact - /// spot to grab. Right-clicking anywhere seeks there directly, no drag required. + /// Builds the right-click menu fresh on every open: first syncs the selection to + /// whatever's under the cursor (so the rest of the app, listening to SelectionChanged, + /// already reflects the right-click target by the time + /// fires), then contributes the canvas's own "Move playback marker here" item -- see its + /// own remarks below -- and lets subscribers add edit-command items via + /// . Cancelled if nothing ends up added (e.g. an empty + /// canvas with no playback segments yet). /// private void OnContentContextMenuOpening(object sender, CancelEventArgs e) { - if (_playbackSegments.Count == 0) + _contextMenuLogicalLocation = _zoom.ToLogical(_contentPanel.PointToClient(System.Windows.Forms.Cursor.Position)); + var hit = _score == null + ? new ScoreHitResult() + : _renderer.HitTest(_score, GetDrawWidth(), _contextMenuLogicalLocation); + + if (hit.HitType != ScoreHitType.None) + { + ApplyHitSelectionForContextMenu(hit); + } + + _contentContextMenu.Items.Clear(); + + // Right-click alternative to dragging the playback marker. Dragging can only *start* + // by grabbing the marker's current (narrow) on-screen position, so on a long score + // the marker effectively feels pinned wherever it last was (typically the very first + // bar, since that's where every load/new/reset leaves it) until the user finds that + // exact spot to grab. Right-clicking anywhere seeks there directly, no drag required. + if (_playbackSegments.Count > 0) + { + _contentContextMenu.Items.Add("Move playback marker here", null, OnMovePlaybackMarkerHereClicked); + } + + ContextMenuOpening?.Invoke(this, new ScoreContextMenuEventArgs(_contentContextMenu, hit.HitType)); + + if (_contentContextMenu.Items.Count == 0) { e.Cancel = true; - return; } + } - _contextMenuLogicalLocation = _zoom.ToLogical(_contentPanel.PointToClient(System.Windows.Forms.Cursor.Position)); + /// Selection-only counterpart to 's hit-type switch: + /// syncs the selection to whatever's under a right-click, but never triggers a click + /// handler's side effects (deleting a chord marker, starting inline text edit, adding a + /// chord slot) -- those belong to a menu item the user explicitly chooses, not to merely + /// opening the menu. Leaves an existing multi-note selection alone when the right-click + /// landed inside it, so "Cut"/"Copy" on the context menu act on the whole selection + /// rather than collapsing it to just the note under the cursor. + private void ApplyHitSelectionForContextMenu(ScoreHitResult hit) + { + switch (hit.HitType) + { + case ScoreHitType.Tie: + if (_selectedTieIndex != hit.TieIndex) + { + SelectTie(hit.TieIndex); + } + + break; + case ScoreHitType.Note: + if (!_selectedNotes.Any(existing => existing.MeasureIndex == hit.MeasureIndex && existing.NoteIndex == hit.NoteIndex) + && !(_selectedNoteIndex == hit.NoteIndex && _selectedMeasureIndex == hit.MeasureIndex)) + { + HandleNoteSelectionClick(hit.MeasureIndex, hit.NoteIndex); + } + + break; + case ScoreHitType.Gap: + SelectSingleMeasure(hit.MeasureIndex, false); + ClearNoteSelection(); + _selectedInsertIndex = hit.InsertIndex; + _selectedTieIndex = -1; + ClearChordSelection(); + RaiseSelectionChanged(); + InvalidateSelection(); + break; + case ScoreHitType.ChordMarker: + case ScoreHitType.ChordDelete: + case ScoreHitType.ChordDragHandle: + if (hit.ChordMarkerIndex >= 0) + { + SelectChordMarker(hit.MeasureIndex, hit.ChordMarkerIndex, startInlineEdit: false); + } + else + { + SelectSingleMeasure(hit.MeasureIndex, false); + } + + break; + case ScoreHitType.ChordAddSlot: + case ScoreHitType.ChordRow: + SelectSingleMeasure(hit.MeasureIndex, false); + ClearChordSelection(); + RaiseSelectionChanged(); + break; + case ScoreHitType.LyricText: + SelectSingleMeasure(hit.MeasureIndex, false); + RaiseSelectionChanged(); + break; + case ScoreHitType.ScoreHeader: + break; + default: + HandleMeasureSelectionClick(hit.MeasureIndex); + break; + } } private void OnMovePlaybackMarkerHereClicked(object sender, EventArgs e) diff --git a/JianpuEditor/Core/Abstractions/INoteClipboardService.cs b/JianpuEditor/Core/Abstractions/INoteClipboardService.cs new file mode 100644 index 0000000..ee32efa --- /dev/null +++ b/JianpuEditor/Core/Abstractions/INoteClipboardService.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using JianpuEditor.Models; + +namespace JianpuEditor.Core.Abstractions +{ + /// + /// In-app clipboard for melody notes, shared across every open tab (a genuine app-wide + /// resource, like ) so cut/copy in one tab can be pasted into + /// another. Not backed by the OS clipboard. + /// + public interface INoteClipboardService + { + bool HasNotes { get; } + + /// Stores a deep copy of ; later mutations to the + /// source notes (or to whatever previously returned) do not + /// affect what's stored. + void SetNotes(IReadOnlyList notes); + + /// Returns a fresh deep copy each call, safe to insert directly into a score. + IReadOnlyList GetNotes(); + } +} diff --git a/JianpuEditor/MainForm.cs b/JianpuEditor/MainForm.cs index 638c16b..f1987ac 100644 --- a/JianpuEditor/MainForm.cs +++ b/JianpuEditor/MainForm.cs @@ -127,6 +127,7 @@ private void AttachTab(DocumentTab tab) tab.Canvas.ChordMarkersChanged += OnCanvasChordMarkersChanged; tab.Canvas.ScoreMutationStarting += OnCanvasScoreMutationStarting; tab.Canvas.PlaybackSeeked += OnCanvasPlaybackSeeked; + tab.Canvas.ContextMenuOpening += OnCanvasContextMenuOpening; tab.Canvas.ZoomChanged += () => { if (ReferenceEquals(ActiveTab, tab)) @@ -676,6 +677,10 @@ private void PopulateMenuStrip(MenuStrip menu) _redoMenuItem = CreateMenuItem("Redo", Keys.Control | Keys.Y, (s, e) => ExecuteRedo()); _redoMenuItem.Enabled = false; editMenu.DropDownItems.Add(_redoMenuItem); + editMenu.DropDownItems.Add(CreateMenuItem("Cut", Keys.Control | Keys.X, (s, e) => ExecuteCut())); + editMenu.DropDownItems.Add(CreateMenuItem("Copy", Keys.Control | Keys.C, (s, e) => ExecuteCopy())); + editMenu.DropDownItems.Add(CreateMenuItem("Paste", Keys.Control | Keys.V, (s, e) => ExecutePaste())); + editMenu.DropDownItems.Add(CreateMenuItem("Delete", Keys.Delete, (s, e) => ExecuteDelete())); editMenu.DropDownItems.Add(CreateMenuItem("Add Measure", Keys.None, (s, e) => ExecuteAddMeasure())); editMenu.DropDownItems.Add(CreateMenuItem( "Add Measure (with placeholders)", @@ -1054,6 +1059,139 @@ private void ExecuteDelete() ExecuteScoreEdit(() => _viewModel.ScoreEditor.Delete()); } + /// Copy, then delete -- but only if there was actually something to copy, so an + /// accidental Cut with nothing selected doesn't fall through to Delete's own "nothing + /// selected" behavior (deleting the last note in the measure). + private void ExecuteCut() + { + if (_viewModel.NoteEditor.CopySelectedNotes()) + { + ExecuteDelete(); + } + } + + private void ExecuteCopy() + { + _viewModel.NoteEditor.CopySelectedNotes(); + } + + private void ExecutePaste() + { + ExecuteNoteEdit(() => _viewModel.NoteEditor.PasteNotes()); + } + + /// Builds the right-click menu's edit-command items, based on what the canvas + /// just told us is under the cursor (it has already synced the selection to match, before + /// raising this event -- see ). + /// Every item here reuses an existing command already wired to the ribbon/menu elsewhere, + /// except Cut/Copy/Paste. + private void OnCanvasContextMenuOpening(object sender, ScoreContextMenuEventArgs e) + { + var menu = e.Menu; + if (menu.Items.Count > 0) + { + menu.Items.Add(new ToolStripSeparator()); + } + + switch (e.HitType) + { + case ScoreHitType.Note: + AddNoteContextMenuItems(menu); + break; + case ScoreHitType.Gap: + AddGapContextMenuItems(menu); + break; + case ScoreHitType.Tie: + menu.Items.Add("Remove Tie", null, (s, args) => ExecuteDelete()); + break; + case ScoreHitType.ChordMarker: + case ScoreHitType.ChordDelete: + case ScoreHitType.ChordDragHandle: + case ScoreHitType.ChordAddSlot: + case ScoreHitType.ChordRow: + AddChordContextMenuItems(menu); + break; + case ScoreHitType.LyricText: + menu.Items.Add("Align Lyrics", null, (s, args) => ExecuteScoreEdit(() => _viewModel.MeasureContent.AlignLyricsToNotes())); + break; + default: + AddMeasureContextMenuItems(menu); + break; + } + } + + private void AddNoteContextMenuItems(ContextMenuStrip menu) + { + menu.Items.Add("Cut", null, (s, e) => ExecuteCut()); + menu.Items.Add("Copy", null, (s, e) => ExecuteCopy()); + menu.Items.Add("Delete", null, (s, e) => ExecuteDelete()); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("Shorten", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.DecreaseDuration())); + menu.Items.Add("Extend", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.IncreaseDuration())); + menu.Items.Add("Toggle Dotted", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.ToggleDotted())); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("Octave Up", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.SetOctave(1))); + menu.Items.Add("Octave Down", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.SetOctave(-1))); + menu.Items.Add("Transpose Up", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.TransposePitch(1))); + menu.Items.Add("Transpose Down", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.TransposePitch(-1))); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("Split", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.SplitSelectedNotes())); + menu.Items.Add("Merge", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.MergeSelectedNotes())); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("Tie Here", null, (s, e) => _viewModel.TieEditor.ToggleTieModeCommand.Execute(null)); + + var ornamentsMenu = new ToolStripMenuItem("Ornaments"); + ornamentsMenu.DropDownItems.Add("Grace Note", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.GraceNote))); + ornamentsMenu.DropDownItems.Add("Trill", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Trill))); + ornamentsMenu.DropDownItems.Add("Turn", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Turn))); + ornamentsMenu.DropDownItems.Add("Fermata", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Fermata))); + menu.Items.Add(ornamentsMenu); + + AddPasteItemIfAvailable(menu); + } + + private void AddGapContextMenuItems(ContextMenuStrip menu) + { + var insertMenu = new ToolStripMenuItem("Insert Note"); + for (var pitch = 1; pitch <= 7; pitch++) + { + var capturedPitch = pitch; + insertMenu.DropDownItems.Add(pitch.ToString(), null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.AddNote(capturedPitch))); + } + + menu.Items.Add(insertMenu); + menu.Items.Add("Insert Rest", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.AddRest())); + AddPasteItemIfAvailable(menu); + } + + private void AddChordContextMenuItems(ContextMenuStrip menu) + { + if (_viewModel.Selection.HasChordSelected) + { + menu.Items.Add("Delete Chord Marker", null, (s, e) => ExecuteDelete()); + } + + menu.Items.Add("Add Chord Marker Here", null, (s, e) => ExecuteScoreEdit(() => _viewModel.ChordEditor.AddChordMarker())); + } + + private void AddMeasureContextMenuItems(ContextMenuStrip menu) + { + menu.Items.Add("Add Measure", null, (s, e) => ExecuteAddMeasure()); + menu.Items.Add("Duplicate Measure(s)", null, (s, e) => ExecuteDuplicateMeasures()); + AddPasteItemIfAvailable(menu); + } + + private void AddPasteItemIfAvailable(ContextMenuStrip menu) + { + if (!_viewModel.NoteEditor.HasClipboardContent) + { + return; + } + + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("Paste", null, (s, e) => ExecutePaste()); + } + private void ExecuteUndo() { if (_isExecutingHistoryChange || !_commandHistory.CanUndo) @@ -1177,6 +1315,27 @@ private void OnFormKeyDown(object sender, KeyEventArgs e) { ExecuteDelete(); e.Handled = true; + return; + } + + if (e.Control && e.KeyCode == Keys.X) + { + ExecuteCut(); + e.Handled = true; + return; + } + + if (e.Control && e.KeyCode == Keys.C) + { + ExecuteCopy(); + e.Handled = true; + return; + } + + if (e.Control && e.KeyCode == Keys.V) + { + ExecutePaste(); + e.Handled = true; } } diff --git a/JianpuEditor/Services/NoteClipboardService.cs b/JianpuEditor/Services/NoteClipboardService.cs new file mode 100644 index 0000000..7ed9671 --- /dev/null +++ b/JianpuEditor/Services/NoteClipboardService.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using JianpuEditor.Core.Abstractions; +using JianpuEditor.Models; + +namespace JianpuEditor.Services +{ + internal sealed class NoteClipboardService : INoteClipboardService + { + private List _notes = new List(); + + public bool HasNotes + { + get { return _notes.Count > 0; } + } + + public void SetNotes(IReadOnlyList notes) + { + _notes = Clone(notes); + } + + public IReadOnlyList GetNotes() + { + return Clone(_notes); + } + + private static List Clone(IReadOnlyList notes) + { + var clone = new List(); + if (notes == null) + { + return clone; + } + + for (var i = 0; i < notes.Count; i++) + { + var source = notes[i]; + clone.Add(new JianpuNote + { + Type = source.Type, + Pitch = source.Pitch, + Accidental = source.Accidental, + Octave = source.Octave, + Underlines = source.Underlines, + Dashes = source.Dashes, + Dotted = source.Dotted + }); + } + + return clone; + } + } +} diff --git a/JianpuEditor/ViewModels/NoteEditorViewModel.cs b/JianpuEditor/ViewModels/NoteEditorViewModel.cs index 285136d..0323ee3 100644 --- a/JianpuEditor/ViewModels/NoteEditorViewModel.cs +++ b/JianpuEditor/ViewModels/NoteEditorViewModel.cs @@ -19,6 +19,7 @@ public sealed class NoteEditorViewModel : ObservableObject private readonly MeasureNavigationViewModel _navigation; private readonly IAppMessenger _messenger; private readonly IEditCommandHistory _history; + private readonly INoteClipboardService _clipboard; private JianpuNote _pendingNote = CreateDefaultNote(); public NoteEditorViewModel( @@ -26,13 +27,15 @@ public NoteEditorViewModel( ScoreSelectionViewModel selection, MeasureNavigationViewModel navigation, IAppMessenger messenger, - IEditCommandHistory history) + IEditCommandHistory history, + INoteClipboardService clipboard) { _document = document ?? throw new ArgumentNullException(nameof(document)); _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _navigation = navigation ?? throw new ArgumentNullException(nameof(navigation)); _messenger = messenger ?? throw new ArgumentNullException(nameof(messenger)); _history = history ?? throw new ArgumentNullException(nameof(history)); + _clipboard = clipboard ?? throw new ArgumentNullException(nameof(clipboard)); AddNoteCommand = new RelayCommand(pitch => AddNote(pitch)); AddRestCommand = new RelayCommand(() => AddRest()); @@ -264,6 +267,69 @@ public ScoreEditResult MergeSelectedNotes() "Merge notes")); } + public bool HasClipboardContent + { + get { return _clipboard.HasNotes; } + } + + /// Copies the selected note(s) to the shared clipboard. Returns false (no-op, + /// nothing copied) when no note is selected, so callers like Cut know not to follow up + /// with a delete. + public bool CopySelectedNotes() + { + var refs = GetOrderedSelectedNoteRefs(); + if (refs.Count == 0) + { + _messenger.Send(new StatusChangedMessage("Select a note to copy first")); + return false; + } + + _document.EnsureMeasures(); + var notes = new List(); + foreach (var noteRef in refs) + { + if (noteRef.MeasureIndex < 0 || noteRef.MeasureIndex >= _document.Score.Measures.Count) + { + continue; + } + + var measureNotes = _document.Score.Measures[noteRef.MeasureIndex].MelodyNotes; + if (noteRef.NoteIndex < 0 || noteRef.NoteIndex >= measureNotes.Count) + { + continue; + } + + notes.Add(measureNotes[noteRef.NoteIndex]); + } + + if (notes.Count == 0) + { + return false; + } + + _clipboard.SetNotes(notes); + _messenger.Send(new StatusChangedMessage(notes.Count > 1 ? "Copied " + notes.Count + " notes" : "Copied note")); + return true; + } + + public ScoreEditResult PasteNotes() + { + var clipboardNotes = _clipboard.GetNotes(); + if (clipboardNotes.Count == 0) + { + _messenger.Send(new StatusChangedMessage("Nothing to paste")); + return ScoreEditResult.Unchanged; + } + + _document.EnsureMeasures(); + var message = clipboardNotes.Count > 1 ? "Pasted " + clipboardNotes.Count + " notes" : "Pasted note"; + return ExecuteCommand(new MeasuresMelodySnapshotCommand( + _document.Score, + _messenger, + () => ApplyPasteNotes(clipboardNotes), + message)); + } + public ScoreEditResult TransposePitch(int delta) { var selectedNotes = GetSelectedNotes(); @@ -571,6 +637,57 @@ private static ScoreEditResult BuildEditResult( }; } + private ScoreEditResult ApplyPasteNotes(IReadOnlyList clipboardNotes) + { + var (measureIndex, insertIndex) = ResolvePasteInsertPoint(); + if (measureIndex < 0 || measureIndex >= _document.Score.Measures.Count) + { + return ScoreEditResult.Unchanged; + } + + var measure = _document.Score.Measures[measureIndex]; + for (var i = 0; i < clipboardNotes.Count; i++) + { + MelodyChordService.InsertSlot(measure, insertIndex + i, clipboardNotes[i]); + } + + return new ScoreEditResult + { + Changed = true, + SelectNoteMeasureIndex = measureIndex, + SelectNoteIndex = insertIndex + clipboardNotes.Count - 1 + }; + } + + /// Pastes into the selected gap, right after the last selected note (in + /// ascending measure/note order), or at the end of the current measure if nothing more + /// specific is selected -- mirrors 's own fallback. + private (int measureIndex, int insertIndex) ResolvePasteInsertPoint() + { + if (_selection.HasGapSelected) + { + var gapMeasureIndex = Math.Max(0, _selection.MeasureIndex); + return (gapMeasureIndex, _selection.InsertIndex); + } + + var refs = GetOrderedSelectedNoteRefs(); + if (refs.Count > 0) + { + var last = refs[refs.Count - 1]; + return (last.MeasureIndex, last.NoteIndex + 1); + } + + var measureIndex = GetCurrentMeasureIndex(); + return (measureIndex, _document.Score.Measures[measureIndex].MelodyNotes.Count); + } + + private List GetOrderedSelectedNoteRefs() + { + var refs = GetSelectedNoteRefs(); + refs.Sort((a, b) => ScoreNoteRef.Compare(a, b)); + return refs; + } + private List GetSelectedNoteRefs() { var result = new List(); diff --git a/README.md b/README.md index 86d37f2..aaeaaf0 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,13 @@ A Jianpu (numbered musical notation) editing tool built on C# WinForms, supporti - **Shift + Left click**: Batch add/remove the range from the anchor to the current note (can span measures) - When multi-selecting across measures, the toolbar's "From / To" measure range automatically syncs the highlight - With multiple selection, toolbar ornament/delete operations etc. apply in batch +- **Cut / Copy / Paste** + - **Ctrl+X** / **Ctrl+C** / **Ctrl+V** (also on the Edit menu and the right-click menu) cut, copy, or paste the selected note(s) + - Paste inserts after the last selected note, or at the selected gap, or at the end of the current measure; works across tabs and across open scores + - An in-app clipboard (not the OS clipboard), separate from the tie/chord/lyric/ornament data attached to the copied notes -- those are not carried over +- **Right-click context menu** + - Context-aware: shows different commands depending on what's under the cursor -- a note (Cut/Copy/Delete, Shorten/Extend, octave/transpose, Split/Merge, Tie, Ornaments), an empty gap (Insert Note/Rest, Paste), a tie (Remove Tie), a chord marker (Add/Delete), lyric text (Align Lyrics), or empty space (Add Measure, Duplicate Measure(s), Paste, and the existing "Move playback marker here") + - Right-clicking a note or chord marker that isn't already selected selects it first, so the menu always acts on what you clicked - **Duration modification** - **Increase (+) / Decrease (-) duration**: cycles through six levels: 1/16 → 1/8 → 1/4 → extend 1 beat → extend 2 beats → extend 3 beats - High octave dot / low octave dot / dot (dotted note) From 067e5dfbcdf9c3387549088ceed788ab49b8e3e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 08:51:33 +0000 Subject: [PATCH 2/3] Fix ViewModelTestHelper to pass the new NoteEditorViewModel clipboard arg CI caught this (PR #29, build check): the real xUnit test project constructs NoteEditorViewModel directly in three places, bypassing DI, so adding the required INoteClipboardService constructor parameter broke its build. This sandbox's Mono/Xvfb harness pipeline doesn't build or run this project (it targets net8.0-windows and needs the real WindowsDesktop workload), so it didn't catch this before the push. Reproduced the exact failure locally by temporarily adding EnableWindowsTargeting to JianpuEditor.Tests.csproj (reverted, not committed) to cross-compile it in this sandbox, confirmed it matches CI's error, and confirmed this fix builds clean with zero errors (same pre-existing CA1707 warnings CI already had). dotnet format --verify-no-changes also passes for both projects. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Pguj4XSScE141p1ScWoqEr --- JianpuEditor.Tests/ViewModels/ViewModelTestHelper.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/JianpuEditor.Tests/ViewModels/ViewModelTestHelper.cs b/JianpuEditor.Tests/ViewModels/ViewModelTestHelper.cs index 9ae228b..5bbe585 100644 --- a/JianpuEditor.Tests/ViewModels/ViewModelTestHelper.cs +++ b/JianpuEditor.Tests/ViewModels/ViewModelTestHelper.cs @@ -55,7 +55,8 @@ public static NoteEditorViewModel CreateNoteEditor( ScoreSelectionViewModel selection, IAppMessenger messenger, IEditCommandHistory history = null, - MeasureNavigationViewModel navigation = null) + MeasureNavigationViewModel navigation = null, + INoteClipboardService clipboard = null) { navigation = navigation ?? CreateMeasureNavigation(document, selection, messenger, history); return new NoteEditorViewModel( @@ -63,7 +64,8 @@ public static NoteEditorViewModel CreateNoteEditor( selection, navigation, messenger, - history ?? CreateHistory(messenger)); + history ?? CreateHistory(messenger), + clipboard ?? new NoteClipboardService()); } public static ChordEditorViewModel CreateChordEditor( @@ -138,7 +140,7 @@ public static MainViewModel CreateMainViewModel() var document = new ScoreDocumentViewModel(new ScoreFileServiceAdapter(), messenger, history); var selection = new ScoreSelectionViewModel(document); var measureNavigation = new MeasureNavigationViewModel(document, selection, messenger, history); - var noteEditor = new NoteEditorViewModel(document, selection, measureNavigation, messenger, history); + var noteEditor = new NoteEditorViewModel(document, selection, measureNavigation, messenger, history, new NoteClipboardService()); var tieEditor = new TieEditorViewModel(document, measureNavigation, messenger, history); var measureContent = new MeasureContentViewModel(document, measureNavigation, messenger, history); var chordEditor = new ChordEditorViewModel( @@ -188,7 +190,7 @@ public static MainViewModel CreateMainViewModel( var document = new ScoreDocumentViewModel(new ScoreFileServiceAdapter(), messenger, history); var selection = new ScoreSelectionViewModel(document); var measureNavigation = new MeasureNavigationViewModel(document, selection, messenger, history); - var noteEditor = new NoteEditorViewModel(document, selection, measureNavigation, messenger, history); + var noteEditor = new NoteEditorViewModel(document, selection, measureNavigation, messenger, history, new NoteClipboardService()); var tieEditor = new TieEditorViewModel(document, measureNavigation, messenger, history); var measureContent = new MeasureContentViewModel(document, measureNavigation, messenger, history); var chordEditor = new ChordEditorViewModel( From 975c2738e77ac7fc689d1806f147d9d67cde1048 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 08:55:37 +0000 Subject: [PATCH 3/3] Register INoteClipboardService in the two other hand-built test DI containers The previous fix only covered ViewModelTestHelper.cs's direct NoteEditorViewModel constructions. CI (PR #29) still failed: 14 tests in MainFormMultiTabTests.cs failed at runtime with the same "Unable to resolve service for type INoteClipboardService" error, because it builds its own ServiceCollection (substituting fakes for hardware-dependent singletons) rather than going through ViewModelTestHelper or AppBootstrapper, and hadn't registered the new service either. DocumentTabTests.cs does the same thing for a narrower DocumentTab-only container -- grepped for every "new ServiceCollection()" in the repo to make sure this was the complete list (three: these two test files, plus AppBootstrapper.cs which was already fixed and never broken). Reproduced this one for real: temporarily added EnableWindowsTargeting to JianpuEditor.Tests.csproj (reverted, not committed) to cross-compile it here, then ran `dotnet test` directly -- it does build and start, but can't actually execute (this sandbox has the compile-time Microsoft.WindowsDesktop.App reference assemblies cached, not the real runtime, so testhost can't launch), confirming this is a compile-and- launch-only environment for this project, same limit as every net8.0- windows check this session. dotnet build succeeds with zero errors (matching CI's pre-existing warnings exactly), and dotnet format --verify-no-changes passes for both projects. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Pguj4XSScE141p1ScWoqEr --- JianpuEditor.Tests/Glue/DocumentTabTests.cs | 1 + JianpuEditor.Tests/MainFormMultiTabTests.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/JianpuEditor.Tests/Glue/DocumentTabTests.cs b/JianpuEditor.Tests/Glue/DocumentTabTests.cs index 06f7aa0..83b5e6d 100644 --- a/JianpuEditor.Tests/Glue/DocumentTabTests.cs +++ b/JianpuEditor.Tests/Glue/DocumentTabTests.cs @@ -94,6 +94,7 @@ private static ServiceProvider BuildServiceProvider() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services.BuildServiceProvider(); } diff --git a/JianpuEditor.Tests/MainFormMultiTabTests.cs b/JianpuEditor.Tests/MainFormMultiTabTests.cs index 598fe4e..2c0bb0e 100644 --- a/JianpuEditor.Tests/MainFormMultiTabTests.cs +++ b/JianpuEditor.Tests/MainFormMultiTabTests.cs @@ -306,6 +306,7 @@ private static ServiceProvider BuildServiceProvider() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); return services.BuildServiceProvider();