App Kotlin Multiplatform (Android/iOS/Desktop) con Compose Multiplatform: controllo intonazione (YIN), pratica guidata, metronomo, ear training, sfide, scansione spartiti via AI, spartiti bundled e pentagramma con glifi tipografici, travature, battute e spaziatura proporzionale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
24 KiB
Staff Rendering Upgrade — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Rendere il pentagramma con glifi raffinati, travature, stanghette di battuta con tempo, e spaziatura proporzionale alla durata, in tutte le viste Compose condivise.
Architecture: La geometria (unità di durata, gruppi di travatura, posizioni battute, pesi/posizioni orizzontali, direzione gambi) vive in StaffLayout (pura, TDD). Le primitive di disegno (Path per teste/code, parallelogrammi per travature, stanghette, cifre tempo, chiave) vivono nel nuovo StaffRenderer.kt e sostituiscono NoteRenderer.kt. Le tre viste diventano orchestratori sottili.
Tech Stack: Kotlin Multiplatform, Compose Multiplatform Canvas/DrawScope, kotlin-test. Spec: docs/superpowers/specs/2026-07-22-staff-rendering-design.md.
Nota: il progetto NON è un repository git — al posto dei commit ogni task termina eseguendo la suite (export JAVA_HOME=$(/usr/libexec/java_home) && ./gradlew :shared:desktopTest).
Task 1: StaffLayout.quarterUnits
Files:
-
Modify:
shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffLayout.kt -
Test:
shared/src/commonTest/kotlin/com/notemusicali/ui/components/StaffGeometryTest.kt(create) -
Step 1: test fallente
package com.notemusicali.ui.components
import com.notemusicali.music.MusicalNote
import com.notemusicali.music.NoteDuration
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class StaffGeometryTest {
@Test
fun `quarter units follow the binary duration ladder`() {
assertEquals(4.0, StaffLayout.quarterUnits(NoteDuration.WHOLE))
assertEquals(2.0, StaffLayout.quarterUnits(NoteDuration.HALF))
assertEquals(1.0, StaffLayout.quarterUnits(NoteDuration.QUARTER))
assertEquals(0.5, StaffLayout.quarterUnits(NoteDuration.EIGHTH))
assertEquals(0.25, StaffLayout.quarterUnits(NoteDuration.SIXTEENTH))
assertEquals(0.125, StaffLayout.quarterUnits(NoteDuration.THIRTY_SECOND))
}
}
- Step 2: verifica RED —
./gradlew :shared:compileTestKotlinDesktop→ erroreUnresolved reference 'quarterUnits' - Step 3: implementazione minima in
StaffLayout:
fun quarterUnits(duration: NoteDuration): Double = when (duration) {
NoteDuration.WHOLE -> 4.0
NoteDuration.HALF -> 2.0
NoteDuration.QUARTER -> 1.0
NoteDuration.EIGHTH -> 0.5
NoteDuration.SIXTEENTH -> 0.25
NoteDuration.THIRTY_SECOND -> 0.125
}
(aggiungere import com.notemusicali.music.NoteDuration)
- Step 4: verifica GREEN —
./gradlew :shared:desktopTest --tests "com.notemusicali.ui.components.StaffGeometryTest"→ PASS
Task 2: StaffLayout.beamGroups + stemUpForGroup
Files: come Task 1.
- Step 1: test fallenti (aggiungere a
StaffGeometryTest; helpern(dur)costruisce una nota DO4 con quella durata)
private fun n(d: NoteDuration) = MusicalNote.fromNameAndOctave(0, 4, d)
@Test
fun `consecutive eighths in the same beat are beamed together`() {
val notes = listOf(n(NoteDuration.EIGHTH), n(NoteDuration.EIGHTH), n(NoteDuration.QUARTER))
assertEquals(listOf(0..1), StaffLayout.beamGroups(notes, beats = 4, beatType = 4))
}
@Test
fun `a lone eighth keeps its flag`() {
val notes = listOf(n(NoteDuration.EIGHTH), n(NoteDuration.QUARTER), n(NoteDuration.EIGHTH))
assertEquals(emptyList(), StaffLayout.beamGroups(notes, 4, 4))
}
@Test
fun `beat boundary splits a run of eighths in four four`() {
// 4 crome: beat 1 = note 0-1, beat 2 = note 2-3
val notes = List(4) { n(NoteDuration.EIGHTH) }
assertEquals(listOf(0..1, 2..3), StaffLayout.beamGroups(notes, 4, 4))
}
@Test
fun `six eight groups eighths in threes`() {
val notes = List(6) { n(NoteDuration.EIGHTH) }
assertEquals(listOf(0..2, 3..5), StaffLayout.beamGroups(notes, 6, 8))
}
@Test
fun `sixteenths beam with eighths inside the beat`() {
val notes = listOf(n(NoteDuration.EIGHTH), n(NoteDuration.SIXTEENTH), n(NoteDuration.SIXTEENTH))
assertEquals(listOf(0..2), StaffLayout.beamGroups(notes, 4, 4))
}
@Test
fun `stem direction for a group follows the majority`() {
assertTrue(StaffLayout.stemUpForGroup(listOf(0, 2, 6))) // 2 su 3 sotto la linea mediana
assertTrue(!StaffLayout.stemUpForGroup(listOf(5, 6, 2))) // 2 su 3 sopra
}
- Step 2: verifica RED — compile error su
beamGroups - Step 3: implementazione in
StaffLayout:
/** Gruppi (indici) di note veloci consecutive che iniziano nello stesso beat. */
fun beamGroups(notes: List<MusicalNote>, beats: Int, beatType: Int): List<IntRange> {
val beatLen = if (beatType == 8 && beats % 3 == 0) 1.5 else 4.0 / beatType
val groups = mutableListOf<IntRange>()
var groupStart = -1
var groupBeat = -1
var pos = 0.0
fun close(endExclusive: Int) {
if (groupStart >= 0 && endExclusive - groupStart >= 2) groups.add(groupStart until endExclusive)
groupStart = -1
}
notes.forEachIndexed { i, note ->
val beamable = quarterUnits(note.duration) <= 0.5
val beat = (pos / beatLen).toInt()
if (beamable) {
if (groupStart < 0) { groupStart = i; groupBeat = beat }
else if (beat != groupBeat) { close(i); groupStart = i; groupBeat = beat }
} else {
close(i)
}
pos += quarterUnits(note.duration)
}
close(notes.size)
return groups
}
/** Gambi in su se la maggioranza delle note del gruppo sta sotto la linea mediana. */
fun stemUpForGroup(relPositions: List<Int>): Boolean =
relPositions.count { it < 4 } * 2 >= relPositions.size
- Step 4: verifica GREEN — suite del file → PASS
Task 3: StaffLayout.measurePositions
Files: come Task 1.
- Step 1: test fallenti
@Test
fun `barlines fall every four quarters in four four`() {
val notes = List(8) { n(NoteDuration.QUARTER) }
assertEquals(listOf(3, 7), StaffLayout.measurePositions(notes, 4, 4))
}
@Test
fun `barlines fall every three quarters in three four`() {
val notes = List(6) { n(NoteDuration.QUARTER) }
assertEquals(listOf(2, 5), StaffLayout.measurePositions(notes, 3, 4))
}
@Test
fun `overshooting note closes the measure after itself`() {
// 2/4: la semibreve (4 quarti) eccede la battuta da 2 → stanghetta dopo di lei
val notes = listOf(n(NoteDuration.WHOLE), n(NoteDuration.QUARTER), n(NoteDuration.QUARTER))
assertEquals(listOf(0, 2), StaffLayout.measurePositions(notes, 2, 4))
}
@Test
fun `empty sequence has no barlines`() {
assertEquals(emptyList(), StaffLayout.measurePositions(emptyList(), 4, 4))
}
- Step 2: verifica RED
- Step 3: implementazione
/** Indici di nota DOPO i quali cade una stanghetta (inclusa l'eventuale finale). */
fun measurePositions(notes: List<MusicalNote>, beats: Int, beatType: Int): List<Int> {
val capacity = beats * (4.0 / beatType)
if (capacity <= 0.0) return emptyList()
val positions = mutableListOf<Int>()
var acc = 0.0
notes.forEachIndexed { i, note ->
acc += quarterUnits(note.duration)
if (acc >= capacity - 1e-9) {
positions.add(i)
acc = 0.0
}
}
return positions
}
- Step 4: verifica GREEN
Task 4: StaffLayout.xPositions (spaziatura proporzionale)
Files: come Task 1.
- Step 1: test fallenti
@Test
fun `x positions give longer notes more room`() {
val notes = listOf(n(NoteDuration.HALF), n(NoteDuration.EIGHTH), n(NoteDuration.EIGHTH))
val xs = StaffLayout.xPositions(notes, areaStart = 0f, areaWidth = 100f)
assertEquals(3, xs.size)
val gapLongToShort = xs[1] - xs[0]
val gapShortToShort = xs[2] - xs[1]
assertTrue(gapLongToShort > gapShortToShort, "la minima deve occupare più spazio: $xs")
assertTrue(xs.all { it in 0f..100f })
}
@Test
fun `single note is centered in the area`() {
val xs = StaffLayout.xPositions(listOf(n(NoteDuration.QUARTER)), 10f, 100f)
assertEquals(60f, xs.single(), absoluteTolerance = 0.5f)
}
- Step 2: verifica RED
- Step 3: implementazione (peso = √durata; centro dello slot)
/** Centri orizzontali proporzionali a sqrt(durata): la nota lunga respira. */
fun xPositions(notes: List<MusicalNote>, areaStart: Float, areaWidth: Float): List<Float> {
if (notes.isEmpty()) return emptyList()
val weights = notes.map { kotlin.math.sqrt(quarterUnits(it.duration)).toFloat() }
val total = weights.sum()
var cursor = 0f
return weights.map { w ->
val center = areaStart + (cursor + w / 2f) / total * areaWidth
cursor += w
center
}
}
- Step 4: verifica GREEN
Task 5: NoteSequence.beats/beatType + parser <time>
Files:
-
Modify:
shared/src/commonMain/kotlin/com/notemusicali/music/NoteSequence.kt -
Modify:
shared/src/commonMain/kotlin/com/notemusicali/music/MusicXmlParser.kt -
Test:
shared/src/commonTest/kotlin/com/notemusicali/music/MusicXmlParserTest.kt -
Step 1: test fallenti (in
MusicXmlParserTest)
@Test
fun parseExtractsTimeSignature() {
val xml = """
<score-partwise><part id="P1"><measure number="1">
<attributes><time><beats>3</beats><beat-type>4</beat-type></time></attributes>
<note><pitch><step>C</step><octave>4</octave></pitch><duration>4</duration></note>
</measure></part></score-partwise>
""".trimIndent()
val sequence = MusicXmlParser.parse(xml)
assertEquals(3, sequence.beats)
assertEquals(4, sequence.beatType)
}
@Test
fun parseDefaultsToFourFourWithoutTimeSignature() {
val xml = """
<score-partwise><part id="P1"><measure number="1">
<note><pitch><step>C</step><octave>4</octave></pitch><duration>4</duration></note>
</measure></part></score-partwise>
""".trimIndent()
val sequence = MusicXmlParser.parse(xml)
assertEquals(4, sequence.beats)
assertEquals(4, sequence.beatType)
}
- Step 2: verifica RED —
Unresolved reference 'beats' - Step 3: implementazione
NoteSequence.kt: aggiungere i campi con default
data class NoteSequence(
val name: String,
val notes: List<MusicalNote>,
val beats: Int = 4,
val beatType: Int = 4,
) { /* corpo esistente invariato */ }
MusicXmlParser.kt: nuova regex + estrazione nel parse
private val TIME_REGEX = Regex(
"""<time\b[^>]*>.*?<beats>\s*(\d+)\s*</beats>.*?<beat-type>\s*(\d+)\s*</beat-type>.*?</time>""",
RegexOption.DOT_MATCHES_ALL,
)
e nel parse, prima del return:
val time = TIME_REGEX.find(partContent)
val beats = time?.groupValues?.get(1)?.toIntOrNull() ?: 4
val beatType = time?.groupValues?.get(2)?.toIntOrNull() ?: 4
return NoteSequence(name = name, notes = notes, beats = beats, beatType = beatType)
- Step 4: verifica GREEN — suite completa desktop → PASS
Task 6: StaffRenderer.kt — primitive di disegno
Files:
- Create:
shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffRenderer.kt
Non testabile a unità (DrawScope): la verifica è la compilazione + l'ispezione visiva del Task 10. Coordinate dei Path prese dai mockup approvati (unità u = lineSpacing * 0.09f, testa larga ~1.35·lineSpacing come oggi).
- Step 1: creare il file con questo contenuto:
package com.notemusicali.ui.components
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.notemusicali.music.MusicalNote
import com.notemusicali.music.NoteDuration
/** Primitive di disegno del pentagramma condivise da tutte le viste. */
private fun unit(lineSpacing: Float) = lineSpacing * 0.09f
internal fun staffHeadHalfWidth(lineSpacing: Float): Float = 7.5f * unit(lineSpacing)
/** Testa "a pera" (dal mockup approvato): piena per <= QUARTER, contorno per WHOLE/HALF. */
internal fun DrawScope.drawNoteHeadPath(
cx: Float, cy: Float,
lineSpacing: Float,
duration: NoteDuration,
color: Color,
) {
val u = unit(lineSpacing)
val path = Path().apply {
moveTo(cx - 7.4f * u, cy + 2.6f * u)
quadraticBezierTo(cx - 5.4f * u, cy - 4.8f * u, cx + 2.4f * u, cy - 4.9f * u)
quadraticBezierTo(cx + 7.6f * u, cy - 4.9f * u, cx + 7.4f * u, cy - 0.9f * u)
quadraticBezierTo(cx + 6.8f * u, cy + 4.9f * u, cx - 1.6f * u, cy + 5.0f * u)
quadraticBezierTo(cx - 7.4f * u, cy + 5.0f * u, cx - 7.4f * u, cy + 2.6f * u)
close()
}
val hollow = duration == NoteDuration.WHOLE || duration == NoteDuration.HALF
if (hollow) drawPath(path, color, style = Stroke(width = lineSpacing * 0.16f))
else drawPath(path, color)
}
/** Gambo attaccato al bordo della testa; ritorna l'estremità (null per la semibreve). */
internal fun DrawScope.drawStem(
cx: Float, cy: Float,
lineSpacing: Float,
stemUp: Boolean,
duration: NoteDuration,
color: Color,
tipY: Float? = null,
): Offset? {
if (duration == NoteDuration.WHOLE) return null
val u = unit(lineSpacing)
val stemH = lineSpacing * 3.2f
val x = if (stemUp) cx + 7.0f * u else cx - 7.0f * u
val endY = tipY ?: if (stemUp) cy - stemH else cy + stemH
drawLine(color, Offset(x, cy), Offset(x, endY), lineSpacing * 0.14f)
return Offset(x, endY)
}
/** Code a goccia riempite (1-3), specchiate per gambo in giù. */
internal fun DrawScope.drawCurvedFlags(
stemTip: Offset,
stemUp: Boolean,
duration: NoteDuration,
lineSpacing: Float,
color: Color,
) {
val count = when (duration) {
NoteDuration.EIGHTH -> 1
NoteDuration.SIXTEENTH -> 2
NoteDuration.THIRTY_SECOND -> 3
else -> return
}
val u = unit(lineSpacing)
val dir = if (stemUp) 1f else -1f
for (i in 0 until count) {
val oy = stemTip.y + dir * i * 5.5f * u
val path = Path().apply {
moveTo(stemTip.x, oy)
cubicTo(
stemTip.x + 7f * u, oy + dir * 4f * u,
stemTip.x + 10f * u, oy + dir * 11f * u,
stemTip.x + 6.5f * u, oy + dir * 21f * u,
)
cubicTo(
stemTip.x + 10.5f * u, oy + dir * 12f * u,
stemTip.x + 8.5f * u, oy + dir * 6f * u,
stemTip.x, oy + dir * 6f * u,
)
close()
}
drawPath(path, color)
}
}
/**
* Gruppo travato: disegna gambi allineati a una travatura inclinata (pendenza
* clampata a una lineSpacing) più la seconda barra tra coppie di semicrome.
*/
internal fun DrawScope.drawBeamedGroup(
xs: List<Float>, ys: List<Float>,
durations: List<NoteDuration>,
stemUp: Boolean,
lineSpacing: Float,
color: Color,
) {
if (xs.size < 2) return
val u = unit(lineSpacing)
val stemH = lineSpacing * 3.2f
val dir = if (stemUp) -1f else 1f
val attachX = xs.map { if (stemUp) it + 7.0f * u else it - 7.0f * u }
// Estremità ideali, poi pendenza clampata; la travatura deve superare tutte le teste
var y0 = ys.first() + dir * stemH
var y1 = ys.last() + dir * stemH
if (y1 - y0 > lineSpacing) y1 = y0 + lineSpacing
if (y0 - y1 > lineSpacing) y0 = y1 + lineSpacing
val minClearance = lineSpacing * 2.2f
ys.forEachIndexed { i, noteY ->
val t = (attachX[i] - attachX.first()) / (attachX.last() - attachX.first())
val beamY = y0 + (y1 - y0) * t
val clearance = (noteY - beamY) * -dir
if (clearance < minClearance) {
val shift = (minClearance - clearance) * dir
y0 += shift; y1 += shift
}
}
fun beamYAt(x: Float) = y0 + (y1 - y0) * (x - attachX.first()) / (attachX.last() - attachX.first())
// Gambi fino alla travatura
xs.indices.forEach { i ->
drawLine(color, Offset(attachX[i], ys[i]), Offset(attachX[i], beamYAt(attachX[i])), lineSpacing * 0.14f)
}
// La travatura si ispessisce VERSO le teste: in giù se i gambi sono in su
val thicken = lineSpacing * 0.45f * (if (stemUp) 1f else -1f)
fun beamSegment(fromX: Float, toX: Float, offset: Float) {
val path = Path().apply {
moveTo(fromX, beamYAt(fromX) + offset)
lineTo(toX, beamYAt(toX) + offset)
lineTo(toX, beamYAt(toX) + offset + thicken)
lineTo(fromX, beamYAt(fromX) + offset + thicken)
close()
}
drawPath(path, color)
}
// Barra primaria su tutto il gruppo
beamSegment(attachX.first(), attachX.last(), 0f)
// Barra secondaria tra coppie adiacenti di semicrome (o più corte)
val secondOffset = lineSpacing * 0.75f * (if (stemUp) 1f else -1f)
for (i in 0 until xs.size - 1) {
val bothShort = quartersOf(durations[i]) <= 0.25 && quartersOf(durations[i + 1]) <= 0.25
if (bothShort) beamSegment(attachX[i], attachX[i + 1], secondOffset)
}
}
private fun quartersOf(d: NoteDuration): Double = StaffLayout.quarterUnits(d)
/** Stanghetta di battuta. */
internal fun DrawScope.drawBarline(x: Float, staffBottom: Float, lineSpacing: Float, color: Color) {
drawLine(color, Offset(x, staffBottom - 4 * lineSpacing), Offset(x, staffBottom), lineSpacing * 0.1f)
}
/** Cifre del tempo (es. 4/4) dopo la chiave. Ritorna la larghezza occupata. */
internal fun DrawScope.drawTimeSignature(
textMeasurer: TextMeasurer,
beats: Int, beatType: Int,
x: Float, staffBottom: Float, lineSpacing: Float,
color: Color,
): Float {
val style = TextStyle(
color = color,
fontSize = (lineSpacing * 1.9f).toSp(),
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Serif,
)
val top = textMeasurer.measure(beats.toString(), style)
val bottom = textMeasurer.measure(beatType.toString(), style)
drawText(top, topLeft = Offset(x, staffBottom - 4f * lineSpacing + (2f * lineSpacing - top.size.height) / 2f))
drawText(bottom, topLeft = Offset(x, staffBottom - 2f * lineSpacing + (2f * lineSpacing - bottom.size.height) / 2f))
return maxOf(top.size.width, bottom.size.width).toFloat()
}
/** Chiave a piena opacità. Ritorna la larghezza occupata. */
internal fun DrawScope.drawClef(
textMeasurer: TextMeasurer,
useTreble: Boolean,
x: Float, staffBottom: Float, lineSpacing: Float,
color: Color,
): Float {
val style = TextStyle(color = color, fontSize = (lineSpacing * 3.4f).toSp(), fontFamily = FontFamily.Serif)
val result = textMeasurer.measure(StaffLayout.clefSymbol(useTreble), style)
val y = if (useTreble) {
(staffBottom - lineSpacing) - result.size.height * 0.42f
} else {
(staffBottom - 3 * lineSpacing) - result.size.height * 0.35f
}
drawText(result, topLeft = Offset(x, y))
return result.size.width.toFloat()
}
- Step 2: compilare —
./gradlew :shared:compileKotlinDesktop→ BUILD SUCCESSFUL
Task 7: riscrittura StaffView.kt (StaffView + StaffPreview)
Files:
-
Modify:
shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffView.kt -
Step 1: StaffView (nota singola) — sostituire il blocco head/stem/flags/accidental con:
drawNoteHeadPath(noteX, noteY, lineSpacing, note.duration, noteColor)
val stemUp = relPos < 4
val tip = drawStem(noteX, noteY, lineSpacing, stemUp, note.duration, noteColor)
tip?.let { drawCurvedFlags(it, stemUp, note.duration, lineSpacing, noteColor) }
drawAccidentalGlyph(note, noteX - staffHeadHalfWidth(lineSpacing) - lineSpacing * 0.7f, noteY, lineSpacing * 0.45f, noteColor)
e il blocco chiave con drawClef(textMeasurer, useTreble, leftMargin + 2f, staffBottom, lineSpacing, clefColor) (dove clefColor = colore note a piena opacità).
- Step 2: StaffPreview — sostituire il calcolo
noteStepuniforme conStaffLayout.xPositions(notes, noteAreaStart, noteAreaWidth); calcolareval groups = StaffLayout.beamGroups(notes, 4, 4)e disegnare le note nei gruppi condrawBeamedGroup(x/y/durate del gruppo,stemUp = StaffLayout.stemUpForGroup(relPos del gruppo)), le altre condrawStem+drawCurvedFlags. Le teste si disegnano per tutte condrawNoteHeadPath. - Step 3: compilare + suite — BUILD SUCCESSFUL, test verdi.
Task 8: riscrittura StaffContextView.kt
Files:
-
Modify:
shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffContextView.kt -
Step 1: firma: aggiungere parametri
beats: Int = 4, beatType: Int = 4aStaffContextView(i chiamanti in PracticeScreen li passano dalla sequenza, con default invariati altrove). -
Step 2: dentro il Canvas:
xs = StaffLayout.xPositions(visibleNotes, noteAreaStart, noteAreaWidth);groups = StaffLayout.beamGroups(visibleNotes, beats, beatType);bars = StaffLayout.measurePositions(notes, beats, beatType)(calcolate sull'intera sequenza, disegnate se l'indice assoluto cade nella finestra:xa metà tra la nota e la successiva). Disegno note: testedrawNoteHeadPathsempre; per gli indici nei gruppi raccogliere x/y/durate e chiamaredrawBeamedGroupuna volta per gruppo con il colore della nota corrente se il gruppo la contiene, altrimenti dim; per gli altridrawStem+drawCurvedFlags. Chiave condrawClef. Evidenziazioni (cerchio oro, etichetta) invariate. -
Step 3: aggiornare il call-site in
PracticeScreen(passarebeats/beatTypedella sequenza corrente). -
Step 4: compilare + suite.
Task 9: riscrittura StaffFullView.kt
Files:
-
Modify:
shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffFullView.kt -
Step 1: firma: aggiungere
beats: Int = 4, beatType: Int = 4; call-site in PracticeScreen aggiornato. -
Step 2: wrapping per battute: pre-calcolare
bars = StaffLayout.measurePositions(notes, beats, beatType)e spezzare le righe sul confine di battuta più vicino alla capienza (notesPerLinediventa il tetto: la riga termina all'ultima stanghetta che ci sta; se una battuta eccede da sola la riga si spezza comunque anotesPerLine). Ogni riga usaStaffLayout.xPositionssul proprio segmento,beamGroupscalcolati per segmento di riga, tempo (4/4) disegnato condrawTimeSignaturesolo sulla prima riga dopo la chiave,drawBarlinealle stanghette interne e a fine riga se coincide con una battuta. -
Step 3: compilare + suite; auto-scroll esistente invariato (usa
currentLine= riga contenentecurrentIndex, ora derivata dalla mappa nota→riga costruita nello step 2).
Task 10: pulizia + verifica completa
- Step 1: eliminare
NoteRenderer.kt(drawNoteHead/drawNoteStem/drawNoteFlags non più referenziati — verificare con grep prima di cancellare). - Step 2: suite completa:
./gradlew :shared:desktopTest :shared:iosSimulatorArm64Test :app:assembleDebug→ tutto verde. - Step 3: lancio app desktop (
./gradlew :desktopApp:run), ispezione visiva di Esercizi (StaffPreview), Pratica in modalità Contesto e Spartito, e Scale; passata di tuning sulle costanti (u, spessori, pendenza) se qualcosa stona.