Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions JianpuEditor.Tests/Glue/DocumentTabTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ private static ServiceProvider BuildServiceProvider()
services.AddSingleton<IHarmonySuggestionService, HarmonySuggestionServiceAdapter>();
services.AddSingleton<SampleLibraryViewModel>();
services.AddSingleton<IPlaybackCoordinator, PlaybackCoordinator>();
services.AddSingleton<INoteClipboardService, NoteClipboardService>();

return services.BuildServiceProvider();
}
Expand Down
1 change: 1 addition & 0 deletions JianpuEditor.Tests/MainFormMultiTabTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
public sealed class MainFormMultiTabTests
{
[Fact]
public void StartsWithOneTab_ShowingTheDemoScore()

Check warning on line 31 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.StartsWithOneTab_ShowingTheDemoScore() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 31 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.StartsWithOneTab_ShowingTheDemoScore() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var provider = BuildServiceProvider();
using var form = CreateForm(provider);
Expand All @@ -41,7 +41,7 @@
}

[Fact]
public void NewScore_OpensSecondTab_WithIndependentDocument()

Check warning on line 44 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.NewScore_OpensSecondTab_WithIndependentDocument() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 44 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.NewScore_OpensSecondTab_WithIndependentDocument() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var provider = BuildServiceProvider();
using var form = CreateForm(provider);
Expand All @@ -64,7 +64,7 @@
}

[Fact]
public void CloseTab_RemovesPage_KeepsOtherTabIntact()

Check warning on line 67 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.CloseTab_RemovesPage_KeepsOtherTabIntact() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 67 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.CloseTab_RemovesPage_KeepsOtherTabIntact() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var provider = BuildServiceProvider();
using var form = CreateForm(provider);
Expand All @@ -84,7 +84,7 @@
}

[Fact]
public void CloseLastTab_OpensFreshBlankTabInstead()

Check warning on line 87 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.CloseLastTab_OpensFreshBlankTabInstead() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 87 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.CloseLastTab_OpensFreshBlankTabInstead() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var provider = BuildServiceProvider();
using var form = CreateForm(provider);
Expand All @@ -99,7 +99,7 @@
}

[Fact]
public void CtrlW_ClosesTheActiveTab()

Check warning on line 102 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.CtrlW_ClosesTheActiveTab() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 102 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.CtrlW_ClosesTheActiveTab() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var provider = BuildServiceProvider();
using var form = CreateForm(provider);
Expand All @@ -117,7 +117,7 @@
}

[Fact]
public void SwitchingAwayFromAPlayingTab_StopsItsPlayback()

Check warning on line 120 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.SwitchingAwayFromAPlayingTab_StopsItsPlayback() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 120 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.SwitchingAwayFromAPlayingTab_StopsItsPlayback() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var provider = BuildServiceProvider();
using var form = CreateForm(provider);
Expand All @@ -135,7 +135,7 @@
}

[Fact]
public void ClosingTheApp_WithNoUnsavedChanges_ClosesAndDisposesEveryTab()

Check warning on line 138 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.ClosingTheApp_WithNoUnsavedChanges_ClosesAndDisposesEveryTab() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 138 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.ClosingTheApp_WithNoUnsavedChanges_ClosesAndDisposesEveryTab() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var provider = BuildServiceProvider();
using var form = CreateForm(provider);
Expand All @@ -155,7 +155,7 @@
}

[Fact]
public void SavingTheSession_CapturesEveryTabsFilePathDirtyStateAndScore()

Check warning on line 158 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.SavingTheSession_CapturesEveryTabsFilePathDirtyStateAndScore() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 158 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.SavingTheSession_CapturesEveryTabsFilePathDirtyStateAndScore() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var provider = BuildServiceProvider();
using var form = CreateForm(provider);
Expand Down Expand Up @@ -216,7 +216,7 @@
}

[Fact]
public void StartupRestoresADirtyTabFromItsAutosaveFile_KeepingTheOriginalFilePathAndDirtyFlag()

Check warning on line 219 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.StartupRestoresADirtyTabFromItsAutosaveFile_KeepingTheOriginalFilePathAndDirtyFlag() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 219 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.StartupRestoresADirtyTabFromItsAutosaveFile_KeepingTheOriginalFilePathAndDirtyFlag() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
var autosaveFile = Path.GetTempFileName();
try
Expand Down Expand Up @@ -253,7 +253,7 @@
}

[Fact]
public void StartupWithASessionReferencingOnlyMissingFiles_FallsBackToTheDemoScore()

Check warning on line 256 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.StartupWithASessionReferencingOnlyMissingFiles_FallsBackToTheDemoScore() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)

Check warning on line 256 in JianpuEditor.Tests/MainFormMultiTabTests.cs

View workflow job for this annotation

GitHub Actions / build

Remove the underscores from member name JianpuEditor.Tests.MainFormMultiTabTests.StartupWithASessionReferencingOnlyMissingFiles_FallsBackToTheDemoScore() (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1707)
{
using var provider = BuildServiceProvider();
var sessionService = (FakeSessionService)provider.GetRequiredService<ISessionService>();
Expand Down Expand Up @@ -306,6 +306,7 @@
services.AddSingleton<ILayoutService, WinFormsLayoutService>();
services.AddSingleton<IPlaybackCoordinator, PlaybackCoordinator>();
services.AddSingleton<ISessionService, FakeSessionService>();
services.AddSingleton<INoteClipboardService, NoteClipboardService>();
services.AddTransient<MainForm>();

return services.BuildServiceProvider();
Expand Down
10 changes: 6 additions & 4 deletions JianpuEditor.Tests/ViewModels/ViewModelTestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,17 @@ 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(
document,
selection,
navigation,
messenger,
history ?? CreateHistory(messenger));
history ?? CreateHistory(messenger),
clipboard ?? new NoteClipboardService());
}

public static ChordEditorViewModel CreateChordEditor(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions JianpuEditor/AppBootstrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ public static ServiceProvider ConfigureServices()
services.AddSingleton<ILayoutService, WinFormsLayoutService>();
services.AddSingleton<IPlaybackCoordinator, PlaybackCoordinator>();
services.AddSingleton<ISessionService, SessionService>();
// Shared across tabs deliberately (like IMidiOutput): cut/copy in one tab, paste in another.
services.AddSingleton<INoteClipboardService, NoteClipboardService>();
services.AddTransient<MainForm>();

return services.BuildServiceProvider();
Expand Down
126 changes: 117 additions & 9 deletions JianpuEditor/Controls/ScoreCanvas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ public bool HasChordSelected
public IReadOnlyList<ScoreNoteRef> SelectedNotes { get; set; } = Array.Empty<ScoreNoteRef>();
}

/// <summary>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
/// <see cref="ScoreCanvas.ApplyHitSelectionForContextMenu"/>). A handler adds its own items
/// to <see cref="Menu"/> based on <see cref="HitType"/>; the canvas contributes only its own
/// intrinsic items (currently just "Move playback marker here").</summary>
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();
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -205,6 +222,8 @@ public void ApplyTheme()

public event Action<double> PlaybackSeeked;

public event EventHandler<ScoreContextMenuEventArgs> ContextMenuOpening;

public JianpuScore Score
{
get { return _score; }
Expand Down Expand Up @@ -814,21 +833,110 @@ private void CommitPlaybackHeadDrag(Point logicalLocation)
}

/// <summary>
/// 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 <see cref="ContextMenuOpening"/>
/// 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
/// <see cref="ContextMenuOpening"/>. Cancelled if nothing ends up added (e.g. an empty
/// canvas with no playback segments yet).
/// </summary>
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));
/// <summary>Selection-only counterpart to <see cref="OnContentClick"/>'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.</summary>
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)
Expand Down
23 changes: 23 additions & 0 deletions JianpuEditor/Core/Abstractions/INoteClipboardService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Collections.Generic;
using JianpuEditor.Models;

namespace JianpuEditor.Core.Abstractions
{
/// <summary>
/// In-app clipboard for melody notes, shared across every open tab (a genuine app-wide
/// resource, like <see cref="IMidiOutput"/>) so cut/copy in one tab can be pasted into
/// another. Not backed by the OS clipboard.
/// </summary>
public interface INoteClipboardService
{
bool HasNotes { get; }

/// <summary>Stores a deep copy of <paramref name="notes"/>; later mutations to the
/// source notes (or to whatever <see cref="GetNotes"/> previously returned) do not
/// affect what's stored.</summary>
void SetNotes(IReadOnlyList<JianpuNote> notes);

/// <summary>Returns a fresh deep copy each call, safe to insert directly into a score.</summary>
IReadOnlyList<JianpuNote> GetNotes();
}
}
Loading
Loading