UI Improvements: Piano widget, rigid sizes, proportions, etc
This commit is contained in:
@@ -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)
|
||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user