UI Improvements: Piano widget, rigid sizes, proportions, etc

This commit is contained in:
Reactorcoremeltdown
2026-07-14 07:37:48 +02:00
parent 3cef1b4e85
commit d380f8643b
6 changed files with 210 additions and 94 deletions

View File

@@ -0,0 +1,107 @@
package com.reactorcoremeltdown.sizzletracker.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
/**
* Lays out one octave as a real piano keyboard: seven white keys across the bottom
* with the five black keys overlaid on top, each centred on the gap between the two
* white keys it sits between (C# D# _ F# G# A#).
*
* Layout-only: the [key] slot renders each key and callers attach their own gesture
* to [keyModifier] (tap-to-enter, hold-to-audition, …). Black keys are emitted last
* so they draw — and receive touches — on top of the white keys they overlap, just
* like a physical keyboard.
*/
@Composable
fun PianoOctave(
modifier: Modifier = Modifier,
blackWidthFraction: Float = 0.62f,
blackHeightFraction: Float = 0.62f,
key: @Composable (pitchClass: Int, isBlack: Boolean, keyModifier: Modifier) -> Unit,
) {
BoxWithConstraints(modifier) {
val whiteW = maxWidth / 7
val blackW = whiteW * blackWidthFraction
val blackH = maxHeight * blackHeightFraction
// White keys: C D E F G A B, filling the width evenly.
Row(Modifier.fillMaxSize()) {
for (pc in WHITE_PCS) key(pc, false, Modifier.weight(1f).fillMaxHeight())
}
// Black keys, each centred on the boundary after a given white-key index.
for ((pc, afterWhite) in BLACK_PCS) {
key(
pc, true,
Modifier
.offset(x = whiteW * (afterWhite + 1) - blackW / 2)
.width(blackW)
.height(blackH),
)
}
}
}
/**
* A single piano key face: light for white keys, dark for black, dimmed when
* [enabled] is false and accent-filled when [selected]. The gesture (if any) is
* supplied by the caller through [modifier].
*/
@Composable
fun PianoKey(
label: String,
isBlack: Boolean,
enabled: Boolean,
selected: Boolean,
modifier: Modifier,
) {
val c = LocalRetro.current
val bg = when {
selected -> c.accent
isBlack -> if (enabled) BLACK_KEY else BLACK_KEY_OFF
else -> if (enabled) WHITE_KEY else WHITE_KEY_OFF
}
val fg = when {
selected -> c.background
isBlack -> if (enabled) Color(0xFFDDDDDD) else Color(0xFF555555)
else -> if (enabled) Color(0xFF141414) else Color(0xFF6A6A6A)
}
Box(
modifier
.background(bg)
.border(1.dp, if (selected) c.accent else KEY_BORDER, RectangleShape),
contentAlignment = Alignment.BottomCenter,
) {
Text(
label, color = fg, fontFamily = FontFamily.Monospace, fontSize = 8.sp,
modifier = Modifier.padding(bottom = 3.dp),
)
}
}
private val WHITE_PCS = intArrayOf(0, 2, 4, 5, 7, 9, 11) // C D E F G A B
// pitch class -> index of the white key it sits immediately after (0=C .. 6=B).
private val BLACK_PCS = listOf(1 to 0, 3 to 1, 6 to 3, 8 to 4, 10 to 5)
private val WHITE_KEY = Color(0xFFE8E8E8)
private val WHITE_KEY_OFF = Color(0xFF5A5A5A)
private val BLACK_KEY = Color(0xFF181820)
private val BLACK_KEY_OFF = Color(0xFF0C0C10)
private val KEY_BORDER = Color(0xFF000000)

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@@ -22,6 +23,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
@@ -33,17 +36,24 @@ import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
* Reusing these keeps every screen visually consistent. * Reusing these keeps every screen visually consistent.
*/ */
/** A flat, square-cornered button. [active] paints it in the accent colour. */ /**
* A flat, square-cornered button. [active] paints it in the accent colour.
* Pass [width] to pin a fixed width (e.g. a button whose label toggles, like
* PLAY/PAUSE) so it never resizes; the single-line label clips with an ellipsis
* if it can't fit.
*/
@Composable @Composable
fun RetroButton( fun RetroButton(
label: String, label: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
active: Boolean = false, active: Boolean = false,
width: Dp? = null,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val c = LocalRetro.current val c = LocalRetro.current
Box( Box(
modifier modifier
.then(if (width != null) Modifier.width(width) else Modifier)
.border(1.dp, if (active) c.accent else c.grid, RectangleShape) .border(1.dp, if (active) c.accent else c.grid, RectangleShape)
.background(if (active) c.accent.copy(alpha = 0.18f) else c.surface) .background(if (active) c.accent.copy(alpha = 0.18f) else c.surface)
.clickable(onClick = onClick) .clickable(onClick = onClick)
@@ -55,6 +65,9 @@ fun RetroButton(
color = if (active) c.accent else c.text, color = if (active) c.accent else c.text,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 12.sp, fontSize = 12.sp,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
) )
} }
} }
@@ -71,6 +84,9 @@ fun <T> RetroDropdown(
) { ) {
val c = LocalRetro.current val c = LocalRetro.current
var open by remember { mutableStateOf(false) } var open by remember { mutableStateOf(false) }
// Reserve the width of the longest option so the field never resizes as the
// selection changes — a common source of toolbar "jitter".
val longest = options.maxByOrNull { optionLabel(it).length }?.let(optionLabel).orEmpty()
Box(modifier) { Box(modifier) {
Box( Box(
Modifier Modifier
@@ -79,9 +95,18 @@ fun <T> RetroDropdown(
.clickable { open = true } .clickable { open = true }
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 8.dp, vertical = 6.dp),
) { ) {
// Invisible sizer (longest option) fixes the width; the visible value
// draws on top and clips with an ellipsis if it ever overflows.
Text(
"$label:$longest",
color = Color.Transparent,
fontFamily = FontFamily.Monospace, fontSize = 12.sp,
maxLines = 1, softWrap = false,
)
Text( Text(
"$label:${optionLabel(selected)}", "$label:${optionLabel(selected)}",
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis,
) )
} }
DropdownMenu(expanded = open, onDismissRequest = { open = false }) { DropdownMenu(expanded = open, onDismissRequest = { open = false }) {

View File

@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -21,6 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.reactorcoremeltdown.sizzletracker.model.Mixer import com.reactorcoremeltdown.sizzletracker.model.Mixer
@@ -74,6 +76,7 @@ private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modi
selected = channel.instrumentSlot, selected = channel.instrumentSlot,
optionLabel = { idx -> if (idx < 0) "" else "${idx + 1}:${toolbox[idx].name}" }, optionLabel = { idx -> if (idx < 0) "" else "${idx + 1}:${toolbox[idx].name}" },
onSelect = { channel.instrumentSlot = it; vm.bumpForToolbar() }, onSelect = { channel.instrumentSlot = it; vm.bumpForToolbar() },
modifier = Modifier.fillMaxWidth(),
) )
SectionLabel("FX 1-4") SectionLabel("FX 1-4")
@@ -91,8 +94,12 @@ private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modi
SectionLabel("MIDI Ch") SectionLabel("MIDI Ch")
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
RetroButton("-") { channel.midiChannel = (channel.midiChannel - 1).coerceAtLeast(1); vm.bumpForToolbar() } RetroButton("-") { channel.midiChannel = (channel.midiChannel - 1).coerceAtLeast(1); vm.bumpForToolbar() }
Text(" ${channel.midiChannel} ", color = c.text, Text(
fontFamily = FontFamily.Monospace, fontSize = 12.sp) "${channel.midiChannel}", color = c.text,
fontFamily = FontFamily.Monospace, fontSize = 12.sp,
textAlign = TextAlign.Center, maxLines = 1,
modifier = Modifier.width(28.dp),
)
RetroButton("+") { channel.midiChannel = (channel.midiChannel + 1).coerceAtMost(16); vm.bumpForToolbar() } RetroButton("+") { channel.midiChannel = (channel.midiChannel + 1).coerceAtMost(16); vm.bumpForToolbar() }
} }
@@ -115,7 +122,8 @@ private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modi
} }
} }
/** A vertical volume fader: fill grows from the bottom; tap or drag to set. */ /** A vertical volume fader: fill grows from the bottom; tap or drag to set.
* Snaps to exactly zero (silence) near the bottom so full-off is easy to hit. */
@Composable @Composable
private fun VerticalFader(value: Float, onValueChange: (Float) -> Unit, modifier: Modifier) { private fun VerticalFader(value: Float, onValueChange: (Float) -> Unit, modifier: Modifier) {
val c = LocalRetro.current val c = LocalRetro.current
@@ -124,12 +132,12 @@ private fun VerticalFader(value: Float, onValueChange: (Float) -> Unit, modifier
.border(1.dp, c.grid, RectangleShape) .border(1.dp, c.grid, RectangleShape)
.background(c.background) .background(c.background)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures { off -> onValueChange((1f - off.y / size.height).coerceIn(0f, 1f)) } detectTapGestures { off -> onValueChange(snapZero(1f - off.y / size.height)) }
} }
.pointerInput(Unit) { .pointerInput(Unit) {
detectDragGestures { change, _ -> detectDragGestures { change, _ ->
change.consume() change.consume()
onValueChange((1f - change.position.y / size.height).coerceIn(0f, 1f)) onValueChange(snapZero(1f - change.position.y / size.height))
} }
}, },
contentAlignment = Alignment.BottomCenter, contentAlignment = Alignment.BottomCenter,
@@ -145,3 +153,11 @@ private fun VerticalFader(value: Float, onValueChange: (Float) -> Unit, modifier
} }
} }
} }
/** Snap the fader to exactly 0 (silence) within a small band of the bottom. */
private fun snapZero(raw: Float): Float {
val v = raw.coerceIn(0f, 1f)
return if (v < ZERO_SNAP_BAND) 0f else v
}
private const val ZERO_SNAP_BAND = 0.03f

View File

@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -41,6 +40,8 @@ import com.reactorcoremeltdown.sizzletracker.model.ToolboxKind
import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot import com.reactorcoremeltdown.sizzletracker.model.ToolboxSlot
import com.reactorcoremeltdown.sizzletracker.model.ToolboxType import com.reactorcoremeltdown.sizzletracker.model.ToolboxType
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
import com.reactorcoremeltdown.sizzletracker.ui.components.PianoKey
import com.reactorcoremeltdown.sizzletracker.ui.components.PianoOctave
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
/** /**
@@ -67,7 +68,9 @@ fun ToolboxScreen(vm: AppViewModel) {
Column(Modifier.fillMaxSize().background(c.background)) { Column(Modifier.fillMaxSize().background(c.background)) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(4), columns = GridCells.Fixed(4),
modifier = Modifier.weight(1f).fillMaxWidth().padding(6.dp), // Same top:bottom split as the tracker/arranger (1.7 : 1): tiles above,
// the piano audition keyboard below.
modifier = Modifier.weight(1.7f).fillMaxWidth().padding(6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
@@ -147,11 +150,10 @@ private fun SlotTile(
// ---------------------------------------------------------- test keyboard // ---------------------------------------------------------- test keyboard
private val ACCIDENTALS = setOf(1, 3, 6, 8, 10) // pitch-classes of the "black keys"
/** /**
* Two stacked octaves (one per row) that audition the selected instrument tile. * Two stacked octaves (one per row), each drawn as a real piano keyboard, that
* Disabled (dimmed) when nothing is selected or the selection is an effect. * audition the selected instrument tile. Disabled (dimmed) when nothing is selected
* or the selection is an effect.
*/ */
@Composable @Composable
private fun AuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot?, modifier: Modifier) { private fun AuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot?, modifier: Modifier) {
@@ -172,36 +174,28 @@ private fun AuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot?, modifier: Mod
) )
val slotIndex = slot?.index ?: -1 val slotIndex = slot?.index ?: -1
val base = 48 // C3 val base = 48 // C3
KeyRow(vm, slotIndex, isInstrument, base + 12, Modifier.weight(1f)) // upper octave (C4..B4) on top PianoRow(vm, slotIndex, isInstrument, base + 12, Modifier.weight(1f)) // upper octave (C4..B4) on top
KeyRow(vm, slotIndex, isInstrument, base, Modifier.weight(1f)) // lower octave (C3..B3) PianoRow(vm, slotIndex, isInstrument, base, Modifier.weight(1f)) // lower octave (C3..B3)
} }
} }
/** One octave of the audition keyboard: every key is playable (hold to sound it)
* when an instrument is selected, since this is for testing, not scale-locked. */
@Composable @Composable
private fun KeyRow(vm: AppViewModel, slotIndex: Int, enabled: Boolean, startMidi: Int, modifier: Modifier) { private fun PianoRow(vm: AppViewModel, slotIndex: Int, enabled: Boolean, startMidi: Int, modifier: Modifier) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(2.dp)) { PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod ->
for (i in 0 until 12) { val midi = startMidi + pc
Key(vm, slotIndex, enabled, startMidi + i, Modifier.weight(1f).fillMaxHeight()) // Keep each key's gesture stable across selection changes: keyed on `midi`
} // only (constant), reading the live slot/enabled via rememberUpdatedState so
} // switching instruments doesn't relaunch every key's coroutine.
}
@Composable
private fun Key(vm: AppViewModel, slotIndex: Int, enabled: Boolean, midi: Int, modifier: Modifier) {
val c = LocalRetro.current
val accidental = (midi % 12) in ACCIDENTALS
val keyColor = (if (accidental) c.grid else c.surface).let { if (enabled) it else it.copy(alpha = 0.4f) }
// Keep the gesture handler stable across selection changes. Keying pointerInput
// on `midi` alone (constant per key) means selecting a different instrument does
// NOT tear down and relaunch all 24 keys' gesture coroutines every tap — the
// live slot/enabled are read through rememberUpdatedState instead.
val liveSlot = rememberUpdatedState(slotIndex) val liveSlot = rememberUpdatedState(slotIndex)
val liveEnabled = rememberUpdatedState(enabled) val liveEnabled = rememberUpdatedState(enabled)
Box( PianoKey(
modifier label = Pitch.name(midi).take(2),
.background(keyColor) isBlack = isBlack,
.border(1.dp, c.grid, RectangleShape) enabled = enabled,
.pointerInput(midi) { selected = false,
modifier = keyMod.pointerInput(midi) {
// Press → note on; release/cancel → note off (proper key behaviour). // Press → note on; release/cancel → note off (proper key behaviour).
awaitEachGesture { awaitEachGesture {
awaitFirstDown() awaitFirstDown()
@@ -215,12 +209,6 @@ private fun Key(vm: AppViewModel, slotIndex: Int, enabled: Boolean, midi: Int, m
} }
} }
}, },
contentAlignment = Alignment.Center,
) {
Text(
Pitch.name(midi),
color = if (accidental) c.textDim else c.text,
fontFamily = FontFamily.Monospace, fontSize = 9.sp,
) )
} }
} }

View File

@@ -29,6 +29,8 @@ import com.reactorcoremeltdown.sizzletracker.model.Cell
import com.reactorcoremeltdown.sizzletracker.model.CellColumn import com.reactorcoremeltdown.sizzletracker.model.CellColumn
import com.reactorcoremeltdown.sizzletracker.model.Pitch import com.reactorcoremeltdown.sizzletracker.model.Pitch
import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel import com.reactorcoremeltdown.sizzletracker.ui.AppViewModel
import com.reactorcoremeltdown.sizzletracker.ui.components.PianoKey
import com.reactorcoremeltdown.sizzletracker.ui.components.PianoOctave
import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton import com.reactorcoremeltdown.sizzletracker.ui.components.RetroButton
import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro import com.reactorcoremeltdown.sizzletracker.ui.theme.LocalRetro
@@ -82,7 +84,8 @@ fun CellEditorPopup(vm: AppViewModel, column: CellColumn, onDismiss: () -> Unit)
} }
} }
/** One octave of piano keys + an octave switcher, writing the note on tap. */ /** A real one-octave piano keyboard + an octave switcher, writing the note on tap.
* Only in-scale keys are enabled (all keys when the scale is Chromatic). */
@Composable @Composable
private fun NoteEditor(vm: AppViewModel, cell: Cell) { private fun NoteEditor(vm: AppViewModel, cell: Cell) {
val c = LocalRetro.current val c = LocalRetro.current
@@ -90,6 +93,11 @@ private fun NoteEditor(vm: AppViewModel, cell: Cell) {
// Start on the octave of the cell's note, or of the last note entered if empty. // Start on the octave of the cell's note, or of the last note entered if empty.
val seedMidi = if (cell.isPlayable) cell.note else vm.lastNote val seedMidi = if (cell.isPlayable) cell.note else vm.lastNote
var octave by remember { mutableIntStateOf((seedMidi / 12 - 1).coerceIn(0, 9)) } var octave by remember { mutableIntStateOf((seedMidi / 12 - 1).coerceIn(0, 9)) }
// Pitch-classes the current scale/root permits (Chromatic yields all twelve).
val root = vm.project.rootNote
val inKey = remember(vm.project.scale, root) {
vm.project.scale.intervals.map { (it + root) % 12 }.toSet()
}
Column( Column(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
@@ -104,16 +112,16 @@ private fun NoteEditor(vm: AppViewModel, cell: Cell) {
Text("OCTAVE $octave", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 14.sp) Text("OCTAVE $octave", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 14.sp)
RetroButton("+") { octave = (octave + 1).coerceAtMost(9) } RetroButton("+") { octave = (octave + 1).coerceAtMost(9) }
} }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(2.dp)) { PianoOctave(Modifier.fillMaxWidth().height(104.dp)) { pc, isBlack, keyMod ->
for (pc in 0 until 12) {
val midi = ((octave + 1) * 12 + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST) val midi = ((octave + 1) * 12 + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
val enabled = pc in inKey
PianoKey( PianoKey(
label = Pitch.name(midi).take(2), label = Pitch.name(midi).take(2),
accidental = pc in ACCIDENTAL_PCS, isBlack = isBlack,
enabled = enabled,
selected = cell.isPlayable && cell.note == midi, selected = cell.isPlayable && cell.note == midi,
modifier = Modifier.weight(1f), modifier = if (enabled) keyMod.clickable { vm.setFocusedNote(midi) } else keyMod,
) { vm.setFocusedNote(midi) } )
}
} }
Text( Text(
"NOTE: " + when { "NOTE: " + when {
@@ -126,37 +134,6 @@ private fun NoteEditor(vm: AppViewModel, cell: Cell) {
} }
} }
@Composable
private fun PianoKey(
label: String,
accidental: Boolean,
selected: Boolean,
modifier: Modifier,
onClick: () -> Unit,
) {
val c = LocalRetro.current
val bg = when {
selected -> c.accent.copy(alpha = 0.45f)
accidental -> c.grid
else -> c.background
}
Box(
modifier
.height(72.dp)
.border(1.dp, if (selected) c.accent else c.grid, RectangleShape)
.background(bg)
.clickable(onClick = onClick),
contentAlignment = Alignment.BottomCenter,
) {
Text(
label,
color = if (accidental) c.textDim else c.text,
fontFamily = FontFamily.Monospace, fontSize = 9.sp,
modifier = Modifier.padding(bottom = 4.dp),
)
}
}
/** A titled value readout with - / + steppers; [onStep] receives -1 or +1. */ /** A titled value readout with - / + steppers; [onStep] receives -1 or +1. */
@Composable @Composable
private fun Stepper(title: String, value: String, onStep: (Int) -> Unit) { private fun Stepper(title: String, value: String, onStep: (Int) -> Unit) {
@@ -177,7 +154,6 @@ private fun Stepper(title: String, value: String, onStep: (Int) -> Unit) {
} }
} }
private val ACCIDENTAL_PCS = setOf(1, 3, 6, 8, 10)
private fun velLabel(cell: Cell) = cell.velocity.toString(16).uppercase().padStart(2, '0') private fun velLabel(cell: Cell) = cell.velocity.toString(16).uppercase().padStart(2, '0')
private fun chanLabel(cell: Cell) = cell.channel.toString().padStart(2, '0') private fun chanLabel(cell: Cell) = cell.channel.toString().padStart(2, '0')
private fun lineLabel(line: Int) = line.toString(16).uppercase().padStart(2, '0') private fun lineLabel(line: Int) = line.toString(16).uppercase().padStart(2, '0')

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -17,6 +18,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
@@ -74,15 +76,17 @@ private fun TransportToolbar(vm: AppViewModel) {
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
RetroButton("⧉ STOP", onClick = vm::stop) RetroButton("⧉ STOP", onClick = vm::stop)
// Fixed width so the button doesn't resize as the label toggles.
RetroButton(if (transport.isPlaying) "❚❚ PAUSE" else "▶ PLAY", RetroButton(if (transport.isPlaying) "❚❚ PAUSE" else "▶ PLAY",
active = transport.isPlaying, onClick = vm::playPause) active = transport.isPlaying, width = 112.dp, onClick = vm::playPause)
// Tempo nudger: tap -/+ around a readout. // Tempo nudger: tap -/+ around a fixed-width readout.
Row { Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
RetroButton("-", onClick = { vm.setTempo(project.tempoBpm - 1) }) RetroButton("-", onClick = { vm.setTempo(project.tempoBpm - 1) })
Text( Text(
" ${project.tempoBpm.toInt()} BPM ", "${project.tempoBpm.toInt()} BPM",
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
modifier = Modifier.padding(vertical = 6.dp), textAlign = TextAlign.Center, maxLines = 1,
modifier = Modifier.width(72.dp).padding(vertical = 6.dp),
) )
RetroButton("+", onClick = { vm.setTempo(project.tempoBpm + 1) }) RetroButton("+", onClick = { vm.setTempo(project.tempoBpm + 1) })
} }
@@ -93,8 +97,6 @@ private fun TransportToolbar(vm: AppViewModel) {
optionLabel = { it.label }, optionLabel = { it.label },
onSelect = vm::setTimeSignature, onSelect = vm::setTimeSignature,
) )
// Insert a note-off ("===") at the cursor.
RetroButton("OFF ===", onClick = vm::noteOffFocused)
} }
// Row 2: block + length + scale + root. // Row 2: block + length + scale + root.
Row( Row(
@@ -141,6 +143,8 @@ private fun TransportToolbar(vm: AppViewModel) {
RetroButton("COPY", onClick = vm::copySelection) RetroButton("COPY", onClick = vm::copySelection)
RetroButton("PASTE", active = vm.canPaste, onClick = vm::pasteClipboard) RetroButton("PASTE", active = vm.canPaste, onClick = vm::pasteClipboard)
RetroButton("DEL", onClick = vm::deleteSelection) RetroButton("DEL", onClick = vm::deleteSelection)
// Insert a note-off ("===") at the cursor.
RetroButton("OFF ===", onClick = vm::noteOffFocused)
} }
} }
} }