From c0ef3be5162cb6c5f2e2eeac8f4a2f911c71db63 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 03:38:28 +0000 Subject: [PATCH] Add breath mark ornament Adds OrnamentType.BreathMark, wired through the same ribbon/Edit-menu/ context-menu pattern as the existing ornaments (Grace/Trill/Turn/ Mordent/Fermata). Unlike those, a breath mark is anchored just after the note's right edge instead of centered above it, since that's where it's placed in notasi angka/jianpu sheet music -- one new branch each in JianpuRenderer.GetOrnamentAnchorX (non-compact layout) and NoteTopAnnotationLayout.GetOrnamentAnchorX (compact/stacked layout). It reuses the existing ornament Y-band and doesn't touch accidental/octave-dot collision math, so it carries none of the layout risk flagged in ROADMAP.md for the accidental slash-convention work. Visual-only: no playback or MIDI-export effect. While scoping the roadmap's next item (a natural/pugar accidental sign), found that AccidentalKind.Sharp/Flat have no manual entry UI anywhere in the app -- accidentals are currently only ever produced by MIDI import. Documented this in ROADMAP.md as a prerequisite gap rather than shipping a natural-sign glyph nothing could attach, and picked breath marks (independently scoped, no prerequisite) instead. Verified via the same sandbox pipeline as every prior change this session: dotnet build succeeds against the real project/package graph, dotnet format --verify-no-changes passes on both projects, and new xUnit tests cover the placeholder glyph and the anchor-position branch. dotnet test itself cannot execute in this sandbox (no WindowsDesktop runtime pack); real confirmation comes from GitHub Actions CI. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Pguj4XSScE141p1ScWoqEr --- .../Rendering/AccidentalRenderingTests.cs | 18 ++++++++++++ .../NoteTopAnnotationPlannerTests.cs | 18 ++++++++++++ .../Services/OrnamentServiceTests.cs | 1 + JianpuEditor/Controls/RibbonIcon.cs | 1 + JianpuEditor/Controls/RibbonIconRenderer.cs | 7 +++++ JianpuEditor/MainForm.cs | 8 +++++- JianpuEditor/Models/OrnamentType.cs | 1 + JianpuEditor/Rendering/JianpuRenderer.cs | 12 +++++++- .../Rendering/NoteTopAnnotationLayout.cs | 12 ++++++-- JianpuEditor/Services/OrnamentService.cs | 2 ++ README.md | 8 +++--- ROADMAP.md | 28 +++++++++++++++---- 12 files changed, 101 insertions(+), 15 deletions(-) diff --git a/JianpuEditor.Tests/Rendering/AccidentalRenderingTests.cs b/JianpuEditor.Tests/Rendering/AccidentalRenderingTests.cs index bf7231b..828b7c7 100644 --- a/JianpuEditor.Tests/Rendering/AccidentalRenderingTests.cs +++ b/JianpuEditor.Tests/Rendering/AccidentalRenderingTests.cs @@ -44,6 +44,24 @@ public void RenderToBitmap_EditorStackedAnnotations_DoesNotThrow() } } + [Fact] + public void RenderToBitmap_BreathMark_DoesNotThrowInCompactOrDefaultLayout() + { + var measure = ScoreTestHelper.Measure(ScoreTestHelper.Note(1), ScoreTestHelper.Note(2)); + measure.Ornaments = new List + { + new JianpuOrnament { Type = OrnamentType.BreathMark, NoteIndex = 0 } + }; + OrnamentService.NormalizeMeasure(measure); + + var score = ScoreTestHelper.CreateScore(measure); + using (var renderer = new JianpuRenderer()) + { + Assert.NotNull(renderer.RenderToBitmap(score, 1280, ScoreLayoutOptions.Editor)); + Assert.NotNull(renderer.RenderToBitmap(score, 1280, ScoreLayoutOptions.Default)); + } + } + [Fact] public void GetAccidentalMark_ReturnsSharpOrFlat() { diff --git a/JianpuEditor.Tests/Rendering/NoteTopAnnotationPlannerTests.cs b/JianpuEditor.Tests/Rendering/NoteTopAnnotationPlannerTests.cs index bbb3789..e172a78 100644 --- a/JianpuEditor.Tests/Rendering/NoteTopAnnotationPlannerTests.cs +++ b/JianpuEditor.Tests/Rendering/NoteTopAnnotationPlannerTests.cs @@ -130,6 +130,24 @@ public void Plan_OctaveOnly_PlacesDotsClosestToNote() Assert.Equal(layout.HeadCenterX - 3f, layout.OctaveDotCenterX, 1); } + [Fact] + public void GetOrnamentAnchorX_BreathMark_AnchorsAfterNoteInsteadOfAboveIt() + { + var layout = NoteTopAnnotationPlanner.Plan( + new JianpuNote { Type = NoteType.Note, Pitch = 1 }, + 100, + 28, + new List(), + compactAccidentals: true); + + var breathAnchor = layout.GetOrnamentAnchorX(OrnamentType.BreathMark, 100, 28); + var trillAnchor = layout.GetOrnamentAnchorX(OrnamentType.Trill, 100, 28); + + Assert.True(breathAnchor > 100 + 28); + Assert.Equal(layout.HeadCenterX, trillAnchor, 1); + Assert.NotEqual(trillAnchor, breathAnchor); + } + [Fact] public void GetOrnamentsForNote_ReturnsOnlyMatchingOrnaments() { diff --git a/JianpuEditor.Tests/Services/OrnamentServiceTests.cs b/JianpuEditor.Tests/Services/OrnamentServiceTests.cs index ad59ee4..e9273e7 100644 --- a/JianpuEditor.Tests/Services/OrnamentServiceTests.cs +++ b/JianpuEditor.Tests/Services/OrnamentServiceTests.cs @@ -97,6 +97,7 @@ public void GetPlaceholderGlyph_ReturnsToolbarLabel() Assert.Equal("tr", OrnamentService.GetPlaceholderGlyph(OrnamentType.Trill)); Assert.Equal("trn", OrnamentService.GetPlaceholderGlyph(OrnamentType.Turn)); Assert.Equal("ferm", OrnamentService.GetPlaceholderGlyph(OrnamentType.Fermata)); + Assert.Equal("br", OrnamentService.GetPlaceholderGlyph(OrnamentType.BreathMark)); } [Fact] diff --git a/JianpuEditor/Controls/RibbonIcon.cs b/JianpuEditor/Controls/RibbonIcon.cs index fc0ad0b..c789107 100644 --- a/JianpuEditor/Controls/RibbonIcon.cs +++ b/JianpuEditor/Controls/RibbonIcon.cs @@ -23,6 +23,7 @@ public enum RibbonIcon Turn, Mordent, Fermata, + BreathMark, Duplicate, Delete, Library, diff --git a/JianpuEditor/Controls/RibbonIconRenderer.cs b/JianpuEditor/Controls/RibbonIconRenderer.cs index 9b31a3d..4232de9 100644 --- a/JianpuEditor/Controls/RibbonIconRenderer.cs +++ b/JianpuEditor/Controls/RibbonIconRenderer.cs @@ -143,6 +143,13 @@ private static void DrawIcon(Graphics g, RibbonIcon icon, Pen pen, Brush brush) case RibbonIcon.Fermata: g.DrawArc(pen, 3f, 6f, 14f, 12f, 180f, 180f); g.FillEllipse(brush, 8.8f, 9.4f, 2.4f, 2.4f); + return; + case RibbonIcon.BreathMark: + using (var breathPen = new Pen(pen.Color, pen.Width * 1.4f)) + { + g.DrawLine(breathPen, 6f, 4f, 13f, 15f); + } + return; case RibbonIcon.Duplicate: g.DrawRectangle(pen, 3f, 6f, 10f, 10f); diff --git a/JianpuEditor/MainForm.cs b/JianpuEditor/MainForm.cs index 03f8491..409f346 100644 --- a/JianpuEditor/MainForm.cs +++ b/JianpuEditor/MainForm.cs @@ -713,6 +713,10 @@ private void PopulateMenuStrip(MenuStrip menu) "Fermata", Keys.None, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Fermata)))); + ornamentMenu.DropDownItems.Add(CreateMenuItem( + "Breath Mark", + Keys.None, + (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.BreathMark)))); editMenu.DropDownItems.Add(ornamentMenu); editMenu.DropDownItems.Add(CreateMenuItem("Clear Score", Keys.None, OnClearScore)); @@ -801,7 +805,8 @@ private FlowLayoutPanel BuildToolbarPanel() CreateRibbonButton(RibbonIcon.Trill, "Trill", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Trill)), compact: true), CreateRibbonButton(RibbonIcon.Turn, "Turn", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Turn)), compact: true), CreateRibbonButton(RibbonIcon.Mordent, "Mordent", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Mordent)), compact: true), - CreateRibbonButton(RibbonIcon.Fermata, "Fermata", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Fermata)), compact: true)); + CreateRibbonButton(RibbonIcon.Fermata, "Fermata", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Fermata)), compact: true), + CreateRibbonButton(RibbonIcon.BreathMark, "Breath mark", () => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.BreathMark)), compact: true)); panel.Controls.Add(ornaments); var measures = new RibbonGroup("Measures"); @@ -1151,6 +1156,7 @@ private void AddNoteContextMenuItems(ContextMenuStrip menu) ornamentsMenu.DropDownItems.Add("Turn", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Turn))); ornamentsMenu.DropDownItems.Add("Mordent", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Mordent))); ornamentsMenu.DropDownItems.Add("Fermata", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Fermata))); + ornamentsMenu.DropDownItems.Add("Breath Mark", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.BreathMark))); menu.Items.Add(ornamentsMenu); AddPasteItemIfAvailable(menu); diff --git a/JianpuEditor/Models/OrnamentType.cs b/JianpuEditor/Models/OrnamentType.cs index 87d8e26..ed5fa60 100644 --- a/JianpuEditor/Models/OrnamentType.cs +++ b/JianpuEditor/Models/OrnamentType.cs @@ -17,6 +17,7 @@ public enum OrnamentType RepeatEnd = 11, Segno = 12, Coda = 13, + BreathMark = 14, Custom = 99 } } diff --git a/JianpuEditor/Rendering/JianpuRenderer.cs b/JianpuEditor/Rendering/JianpuRenderer.cs index 7a09387..186e84c 100644 --- a/JianpuEditor/Rendering/JianpuRenderer.cs +++ b/JianpuEditor/Rendering/JianpuRenderer.cs @@ -1363,7 +1363,7 @@ private void DrawOrnamentGlyph( private static bool UsesLatinOrnamentFont(OrnamentType type) { - return type == OrnamentType.Trill || type == OrnamentType.Mordent; + return type == OrnamentType.Trill || type == OrnamentType.Mordent || type == OrnamentType.BreathMark; } private static float GetOrnamentAnchorX(OrnamentType type, int noteX, int noteWidth) @@ -1374,6 +1374,11 @@ private static float GetOrnamentAnchorX(OrnamentType type, int noteX, int noteWi return noteX + Math.Min(14f, headWidth * 0.25f); } + if (type == OrnamentType.BreathMark) + { + return noteX + headWidth + BreathMarkGap; + } + return GetNoteHeadCenterX(noteX, noteWidth); } @@ -1940,6 +1945,11 @@ private void DrawTextRowCore( private const float CompactAccidentalFontSize = 10f; + /// Gap between a note's right edge and a breath mark anchored just after it -- + /// breath marks sit in the space between notes, unlike every other ornament here, which is + /// centered above the note itself. + private const float BreathMarkGap = 3f; + private void DrawSimultaneousNotes( Graphics g, JianpuMeasure measure, diff --git a/JianpuEditor/Rendering/NoteTopAnnotationLayout.cs b/JianpuEditor/Rendering/NoteTopAnnotationLayout.cs index e141325..3e00a6d 100644 --- a/JianpuEditor/Rendering/NoteTopAnnotationLayout.cs +++ b/JianpuEditor/Rendering/NoteTopAnnotationLayout.cs @@ -31,6 +31,10 @@ public sealed class NoteTopAnnotationLayout public const float AccidentalMarkWidth = 8f; + /// Same gap 's non-compact layout path uses -- kept as + /// its own constant here since this class has no reference to JianpuRenderer's. + private const float BreathMarkGap = 3f; + public float HeadCenterX { get; set; } public float OctaveDotCenterX { get; set; } @@ -59,9 +63,11 @@ public sealed class NoteTopAnnotationLayout public float GetOrnamentAnchorX(OrnamentType type, int noteX, int headWidth) { - _ = type; - _ = noteX; - _ = headWidth; + if (type == OrnamentType.BreathMark) + { + return noteX + headWidth + BreathMarkGap; + } + return HeadCenterX; } diff --git a/JianpuEditor/Services/OrnamentService.cs b/JianpuEditor/Services/OrnamentService.cs index 6e70778..9780f09 100644 --- a/JianpuEditor/Services/OrnamentService.cs +++ b/JianpuEditor/Services/OrnamentService.cs @@ -225,6 +225,8 @@ public static string GetPlaceholderGlyph(OrnamentType type) return "ferm"; case OrnamentType.Mordent: return "mor"; + case OrnamentType.BreathMark: + return "br"; default: return type.ToString(); } diff --git a/README.md b/README.md index f7ce024..2086873 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,12 @@ A Jianpu (numbered musical notation) editing tool built on C# WinForms, supporti - Menu **Edit → Undo** (**Ctrl+Z**) / **Redo** (**Ctrl+Y**) steps backward or forward through score edits - Covers note editing, deletion, measures, transposition, header/lyric/chord inline editing, ties, ornaments, bulk lyric editing, etc.; the undo/redo stack is cleared after creating, opening, or loading a score - **Ornaments** - - Toolbar "Ornaments" section: **Grace note** / **Trill** / **Turn** / **Mordent** / **Fermata**; the menu **Edit → Ornaments** provides the same options + - Toolbar "Ornaments" section: **Grace note** / **Trill** / **Turn** / **Mordent** / **Fermata** / **Breath Mark**; the menu **Edit → Ornaments** provides the same options - Select one or more notes first, then click an ornament button; with multiple selection, ornaments are added in batch - Clicking the same button again removes that type of ornament from the note (other types are kept) - Delete / "Delete" removes ornaments on the selected note first - - The canvas and PDF export draw placeholder symbols above the note (grace / tr / turn / fermata) - - Score playback and MIDI export expand grace notes, trills, turns, mordents, and fermata durations + - The canvas and PDF export draw placeholder symbols above the note (grace / tr / turn / fermata); a breath mark draws just after the note instead, matching where it's placed in notasi angka/jianpu sheet music + - Score playback and MIDI export expand grace notes, trills, turns, mordents, and fermata durations; a breath mark is visual-only and doesn't affect playback/export - **Ties** - Click "Tie" → select the start note → select the end note; Esc to cancel - The end note must be the same pitch as the start note (a tie sustains one pitch); picking a @@ -185,7 +185,7 @@ You can also manually specify a version number in GitHub under **Actions → Rel | Split / Merge | Splits or merges the duration of selected notes | | Toolbar "From / To" + Copy Measures | Copies measures within the specified range | | Tie | Click "Tie" → select the start/end note; click the arc to select it, Delete to remove | -| Ornaments | Select a note, then click "Grace Note / Trill / Turn / Mordent / Fermata" in the toolbar; click the same button again to remove it | +| Ornaments | Select a note, then click "Grace Note / Trill / Turn / Mordent / Fermata / Breath Mark" in the toolbar; click the same button again to remove it | | Undo / Redo | **Edit → Undo / Redo** or **Ctrl+Z** / **Ctrl+Y** | | Play / Stop | Plays the score according to BPM; drag the blue progress bar to seek | | Transpose | Menu "Edit → Chord Transpose..."; transposes chord markers only | diff --git a/ROADMAP.md b/ROADMAP.md index b6058a8..95ad41a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,9 +29,10 @@ Until now, playback always used whatever General MIDI patch 0 (Acoustic Grand Pi ## Indonesian notasi angka completeness (gap analysis + plan) -**Status: phase 3's bug-fix half and the Mordent half of phase 4 are done. Everything else below -is still not started**, including phases 1-2 (`NotationStyle` + the accidental slash convention) --- see the note under phase 2 for why that one turned out to be more involved than it looked. +**Status: phase 3's bug-fix half, the Mordent half of phase 4, and phase 6 (breath marks) are +done. Everything else below is still not started**, including phases 1-2 (`NotationStyle` + the +accidental slash convention) -- see the note under phase 2 for why that one turned out to be more +involved than it looked, and the new note there about the natural sign specifically. Researched Indonesian *notasi angka* (Indonesian numbered/jianpu notation — rules, symbols, conventions) against what `JianpuEditor` actually implements. Full gap list and phased plan below. @@ -102,6 +103,15 @@ here and intentionally excluded.* doesn't carry accidentals through a measure, so it's just a third independent glyph state) can land separately and first, since it doesn't touch the suffix-vs-prefix positioning question at all. + **Checked before starting this, found a bigger prerequisite gap**: `AccidentalKind.Sharp`/`Flat` + have zero manual entry UI anywhere in the app today (`JianpuPitchCodec.SetAccidentalPitch` is + never called outside its own definition and tests) — the only code path that ever produces an + accidental note is `MidiImportService` reading a chromatic pitch out of an imported file. So + before a `Natural` sign is actually reachable by a user hand-notating a score (as opposed to one + that only shows up after a MIDI import), this needs a manual sharp/flat/natural entry command + too — a real, if small, UI feature of its own, not just a third glyph case. Scoping that + alongside the glyph work, rather than shipping a glyph nothing can ever attach, is the right + order once this phase is picked up. 3. **Tie/slur pitch bug: the validation half is done; slur support is not.** Added a same-pitch check to `TieEditorViewModel.TryCompleteTie` (rejects with a status message and treats the mismatched note as a new start candidate, mirroring the existing "must come after" rejection @@ -124,9 +134,15 @@ here and intentionally excluded.* plus optionally a hairpin start/end pair), a small "add dynamic here" UI mirroring how chord markers already attach to a beat, rendering below the melody row, and a playback/MIDI-export velocity-scaling pass applied to notes until the next marking. -6. **Breath marks.** Simplest of the remaining additive items — fold into the existing - `Ornaments` per-note-index list (`OrnamentType.BreathMark`, new value), visual-only glyph, no - playback effect in v1 (a version that inserts a micro-rest is a possible follow-up, not v1). +6. **Breath marks — done.** Added `OrnamentType.BreathMark`, folded into the existing per-note + ornament list/UI pattern (ribbon + Edit menu + context menu, same as Mordent). Unlike every + other ornament here, it's anchored just *after* the note's right edge instead of centered above + it (`JianpuRenderer.GetOrnamentAnchorX` / `NoteTopAnnotationLayout.GetOrnamentAnchorX`, one new + branch each) since that's where a breath mark actually sits in notasi angka/jianpu sheet music + — but it deliberately reuses the existing ornament stacking band for its Y position and doesn't + participate in `NoteTopAnnotationPlanner`'s accidental/octave-dot collision math at all, so it + carries none of the layout risk flagged under phase 2. Visual-only, no playback/MIDI-export + effect (a version that inserts a micro-rest is a possible follow-up, not v1). 7. **Repeat bar lines, volta brackets, and D.C./D.S./Coda/Segno navigation.** Two sub-parts with very different risk: - **Visual-only** (lower risk): a `BarLineType` field per measure boundary (single/double/