Files
intono/shared/src/commonMain/kotlin/com/notemusicali/ui/components/StaffContextView.kt
T
lucianoandClaude Fable 5 21ad969b10 Initial commit: InTono — app KMP per l'apprendimento musicale
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>
2026-07-22 10:06:59 +02:00

171 lines
6.8 KiB
Kotlin

package com.notemusicali.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.notemusicali.music.MusicalNote
/**
* Shows a window of notes around the current index, with the current note highlighted.
* [windowSize] controls how many notes are visible (odd number works best).
*/
@Composable
fun StaffContextView(
notes: List<MusicalNote>,
currentIndex: Int,
modifier: Modifier = Modifier,
windowSize: Int = 7,
lightTheme: Boolean = false,
beats: Int = 4,
beatType: Int = 4,
) {
if (notes.isEmpty()) return
val textMeasurer = rememberTextMeasurer()
Canvas(
modifier = modifier
.fillMaxWidth()
.height(140.dp),
) {
val lineColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.4f)
val dimNoteColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.35f)
val playedNoteColor = if (lightTheme) Color.Black.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.2f)
val currentNoteColor = Color(0xFFFFD54F)
val clefColor = if (lightTheme) Color.Black else Color.White.copy(alpha = 0.8f)
val lineSpacing = size.height / 10f
val staffBottom = size.height / 2f + lineSpacing * 2f
val halfSpacing = lineSpacing / 2f
val leftMargin = size.width * 0.06f
val rightMargin = size.width * 0.02f
val useTreble = StaffLayout.useTrebleClef(notes)
// Staff lines
for (i in 0..4) {
val y = staffBottom - i * lineSpacing
drawLine(lineColor, Offset(leftMargin, y), Offset(size.width - rightMargin, y), 1.5f)
}
// Determine visible window
val half = windowSize / 2
val startIdx = (currentIndex - half).coerceAtLeast(0)
val endIdx = (startIdx + windowSize).coerceAtMost(notes.size)
val visibleNotes = notes.subList(startIdx, endIdx)
// Clef (+ time signature when the window includes the start of the piece)
var cursorX = leftMargin + 2f
cursorX += drawClef(textMeasurer, useTreble, cursorX, staffBottom, lineSpacing, clefColor)
if (startIdx == 0) {
cursorX += 6f
cursorX += drawTimeSignature(textMeasurer, beats, beatType, cursorX, staffBottom, lineSpacing, clefColor)
}
val noteAreaStart = cursorX + 10f
val noteAreaEnd = size.width - rightMargin - 8f
val noteAreaWidth = noteAreaEnd - noteAreaStart
val xs = StaffLayout.xPositions(visibleNotes, noteAreaStart, noteAreaWidth)
// Beam groups on the full sequence, clipped to the visible window (window-local indices)
val groups = StaffLayout.beamGroups(notes, beats, beatType)
.mapNotNull { g ->
val from = maxOf(g.first, startIdx)
val to = minOf(g.last, endIdx - 1)
if (to - from >= 1) (from - startIdx)..(to - startIdx) else null
}
val grouped = groups.flatMap { it }.toSet()
val bars = StaffLayout.measurePositions(notes, beats, beatType).toSet()
val headHalf = staffHeadHalfWidth(lineSpacing)
val ledgerHalf = lineSpacing * 0.8f
visibleNotes.forEachIndexed { vi, note ->
val actualIdx = startIdx + vi
val nx = xs[vi]
val isCurrent = actualIdx == currentIndex
val isPlayed = actualIdx < currentIndex
val color = when {
isCurrent -> currentNoteColor
isPlayed -> playedNoteColor
else -> dimNoteColor
}
val relPos = StaffLayout.relativePosition(note, useTreble)
val ny = staffBottom - relPos * halfSpacing
// Ledger lines
var lp = -2
while (lp >= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(lineColor.copy(alpha = 0.3f), Offset(nx - ledgerHalf, ly), Offset(nx + ledgerHalf, ly), 1f)
lp -= 2
}
lp = 10
while (lp <= relPos) {
val ly = staffBottom - lp * halfSpacing
drawLine(lineColor.copy(alpha = 0.3f), Offset(nx - ledgerHalf, ly), Offset(nx + ledgerHalf, ly), 1f)
lp += 2
}
// Note head
drawNoteHeadPath(nx, ny, lineSpacing, note.duration, color)
// Stem + flags (only for notes not part of a beamed group)
if (vi !in grouped) {
val stemUp = relPos < 4
val stemTip = drawStem(nx, ny, lineSpacing, stemUp, note.duration, color)
stemTip?.let { drawCurvedFlags(it, stemUp, note.duration, lineSpacing, color) }
}
// Sharp
drawAccidentalGlyph(note, nx - headHalf - lineSpacing * 0.5f, ny, lineSpacing * 0.35f, color)
// Barline between this note and the next
if (actualIdx in bars && vi < visibleNotes.lastIndex) {
drawBarline((xs[vi] + xs[vi + 1]) / 2f, staffBottom, lineSpacing, lineColor)
}
// Current note highlight circle
if (isCurrent) {
drawCircle(currentNoteColor.copy(alpha = 0.15f), radius = lineSpacing * 2f, center = Offset(nx, ny))
}
// Note name under current
if (isCurrent) {
val labelStyle = TextStyle(color = currentNoteColor, fontSize = 12.sp)
val labelResult = textMeasurer.measure(note.fullName, labelStyle)
drawText(labelResult, topLeft = Offset(nx - labelResult.size.width / 2f, staffBottom + lineSpacing * 1.2f))
}
}
// Beamed groups
val currentVi = currentIndex - startIdx
groups.forEach { group ->
val gxs = group.map { xs[it] }
val relPositions = group.map { StaffLayout.relativePosition(visibleNotes[it], useTreble) }
val gys = relPositions.map { staffBottom - it * halfSpacing }
val gdurations = group.map { visibleNotes[it].duration }
val groupColor = if (currentVi in group) currentNoteColor else dimNoteColor
drawBeamedGroup(
xs = gxs,
ys = gys,
durations = gdurations,
stemUp = StaffLayout.stemUpForGroup(relPositions),
lineSpacing = lineSpacing,
color = groupColor,
)
}
}
}