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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -22,6 +23,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.sp
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.
*/
/** 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
fun RetroButton(
label: String,
modifier: Modifier = Modifier,
active: Boolean = false,
width: Dp? = null,
onClick: () -> Unit,
) {
val c = LocalRetro.current
Box(
modifier
.then(if (width != null) Modifier.width(width) else Modifier)
.border(1.dp, if (active) c.accent else c.grid, RectangleShape)
.background(if (active) c.accent.copy(alpha = 0.18f) else c.surface)
.clickable(onClick = onClick)
@@ -55,6 +65,9 @@ fun RetroButton(
color = if (active) c.accent else c.text,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -71,6 +84,9 @@ fun <T> RetroDropdown(
) {
val c = LocalRetro.current
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
@@ -79,9 +95,18 @@ fun <T> RetroDropdown(
.clickable { open = true }
.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(
"$label:${optionLabel(selected)}",
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis,
)
}
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.height
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.runtime.getValue
@@ -21,6 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.pointer.pointerInput
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.sp
import com.reactorcoremeltdown.sizzletracker.model.Mixer
@@ -74,6 +76,7 @@ private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modi
selected = channel.instrumentSlot,
optionLabel = { idx -> if (idx < 0) "" else "${idx + 1}:${toolbox[idx].name}" },
onSelect = { channel.instrumentSlot = it; vm.bumpForToolbar() },
modifier = Modifier.fillMaxWidth(),
)
SectionLabel("FX 1-4")
@@ -91,8 +94,12 @@ private fun ChannelStrip(vm: AppViewModel, channel: MixerChannel, modifier: Modi
SectionLabel("MIDI Ch")
Row(verticalAlignment = Alignment.CenterVertically) {
RetroButton("-") { channel.midiChannel = (channel.midiChannel - 1).coerceAtLeast(1); vm.bumpForToolbar() }
Text(" ${channel.midiChannel} ", color = c.text,
fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text(
"${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() }
}
@@ -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
private fun VerticalFader(value: Float, onValueChange: (Float) -> Unit, modifier: Modifier) {
val c = LocalRetro.current
@@ -124,12 +132,12 @@ private fun VerticalFader(value: Float, onValueChange: (Float) -> Unit, modifier
.border(1.dp, c.grid, RectangleShape)
.background(c.background)
.pointerInput(Unit) {
detectTapGestures { off -> onValueChange((1f - off.y / size.height).coerceIn(0f, 1f)) }
detectTapGestures { off -> onValueChange(snapZero(1f - off.y / size.height)) }
}
.pointerInput(Unit) {
detectDragGestures { change, _ ->
change.consume()
onValueChange((1f - change.position.y / size.height).coerceIn(0f, 1f))
onValueChange(snapZero(1f - change.position.y / size.height))
}
},
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.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.ToolboxType
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
/**
@@ -67,7 +68,9 @@ fun ToolboxScreen(vm: AppViewModel) {
Column(Modifier.fillMaxSize().background(c.background)) {
LazyVerticalGrid(
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),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
@@ -147,11 +150,10 @@ private fun SlotTile(
// ---------------------------------------------------------- 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.
* Disabled (dimmed) when nothing is selected or the selection is an effect.
* Two stacked octaves (one per row), each drawn as a real piano keyboard, that
* audition the selected instrument tile. Disabled (dimmed) when nothing is selected
* or the selection is an effect.
*/
@Composable
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 base = 48 // C3
KeyRow(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 + 12, Modifier.weight(1f)) // upper octave (C4..B4) on top
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
private fun KeyRow(vm: AppViewModel, slotIndex: Int, enabled: Boolean, startMidi: Int, modifier: Modifier) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(2.dp)) {
for (i in 0 until 12) {
Key(vm, slotIndex, enabled, startMidi + i, Modifier.weight(1f).fillMaxHeight())
}
}
}
@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 liveEnabled = rememberUpdatedState(enabled)
Box(
modifier
.background(keyColor)
.border(1.dp, c.grid, RectangleShape)
.pointerInput(midi) {
private fun PianoRow(vm: AppViewModel, slotIndex: Int, enabled: Boolean, startMidi: Int, modifier: Modifier) {
PianoOctave(modifier.fillMaxWidth()) { pc, isBlack, keyMod ->
val midi = startMidi + pc
// 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.
val liveSlot = rememberUpdatedState(slotIndex)
val liveEnabled = rememberUpdatedState(enabled)
PianoKey(
label = Pitch.name(midi).take(2),
isBlack = isBlack,
enabled = enabled,
selected = false,
modifier = keyMod.pointerInput(midi) {
// Press → note on; release/cancel → note off (proper key behaviour).
awaitEachGesture {
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.Pitch
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.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
private fun NoteEditor(vm: AppViewModel, cell: Cell) {
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.
val seedMidi = if (cell.isPlayable) cell.note else vm.lastNote
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(
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)
RetroButton("+") { octave = (octave + 1).coerceAtMost(9) }
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(2.dp)) {
for (pc in 0 until 12) {
val midi = ((octave + 1) * 12 + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
PianoKey(
label = Pitch.name(midi).take(2),
accidental = pc in ACCIDENTAL_PCS,
selected = cell.isPlayable && cell.note == midi,
modifier = Modifier.weight(1f),
) { vm.setFocusedNote(midi) }
}
PianoOctave(Modifier.fillMaxWidth().height(104.dp)) { pc, isBlack, keyMod ->
val midi = ((octave + 1) * 12 + pc).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
val enabled = pc in inKey
PianoKey(
label = Pitch.name(midi).take(2),
isBlack = isBlack,
enabled = enabled,
selected = cell.isPlayable && cell.note == midi,
modifier = if (enabled) keyMod.clickable { vm.setFocusedNote(midi) } else keyMod,
)
}
Text(
"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. */
@Composable
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 chanLabel(cell: Cell) = cell.channel.toString().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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -17,6 +18,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
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.sp
import com.reactorcoremeltdown.sizzletracker.model.TimeSignature
@@ -74,15 +76,17 @@ private fun TransportToolbar(vm: AppViewModel) {
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
RetroButton("⧉ STOP", onClick = vm::stop)
// Fixed width so the button doesn't resize as the label toggles.
RetroButton(if (transport.isPlaying) "❚❚ PAUSE" else "▶ PLAY",
active = transport.isPlaying, onClick = vm::playPause)
// Tempo nudger: tap -/+ around a readout.
Row {
active = transport.isPlaying, width = 112.dp, onClick = vm::playPause)
// Tempo nudger: tap -/+ around a fixed-width readout.
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
RetroButton("-", onClick = { vm.setTempo(project.tempoBpm - 1) })
Text(
" ${project.tempoBpm.toInt()} BPM ",
"${project.tempoBpm.toInt()} BPM",
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) })
}
@@ -93,8 +97,6 @@ private fun TransportToolbar(vm: AppViewModel) {
optionLabel = { it.label },
onSelect = vm::setTimeSignature,
)
// Insert a note-off ("===") at the cursor.
RetroButton("OFF ===", onClick = vm::noteOffFocused)
}
// Row 2: block + length + scale + root.
Row(
@@ -141,6 +143,8 @@ private fun TransportToolbar(vm: AppViewModel) {
RetroButton("COPY", onClick = vm::copySelection)
RetroButton("PASTE", active = vm.canPaste, onClick = vm::pasteClipboard)
RetroButton("DEL", onClick = vm::deleteSelection)
// Insert a note-off ("===") at the cursor.
RetroButton("OFF ===", onClick = vm::noteOffFocused)
}
}
}