Demos/BeatPad/ChannelRackBlob.cs
using System;
using System.Collections.Generic;
using Goo;
using Goo.Input;
using Sandbox.UI;
using static Sandbox.BeatPadTokens;
namespace Sandbox;
// pure presenter for the FL-style channel rack: step ruler, one row per active track, sweeping playhead. state in BeatPadUI; fixed-px cells make the rack content-sized.
internal static class ChannelRackBlob
{
public readonly record struct Track(
int Pad, string Label, bool[] Occupied, bool Muted, bool Soloed, bool Silenced);
public readonly record struct Props(
IReadOnlyList<Track> Tracks,
int Steps,
float PlayheadFraction,
HandlerTable<(int, int)> CellClick, // (step, pad)
HandlerTable<int> TrackDelete, // pad
HandlerTable<int> TrackMute, // pad
HandlerTable<int> TrackSolo); // pad
// layout constants (px)
const float CellW = 16f, CellH = 18f, CellGap = 3f, BeatGap = 6f;
const float LabelW = 44f, LabelGap = 4f, BtnW = 16f, CtrlGap = 3f, HeadGap = 6f;
const float RowGap = 4f, RulerH = 12f, Pad = 8f;
// x where the step cells start: label, then the S/M/X control buttons.
const float LaneLeft = LabelW + LabelGap + 3f * BtnW + 2f * CtrlGap + HeadGap;
// trailing margin after step i: bigger gap at the end of each 4-beat group, none after the last.
static float CellMargin(int i, int steps) =>
i == steps - 1 ? 0f : ((i % 4 == 3) ? BeatGap : CellGap);
public static Container Build(Props p)
{
int steps = p.Steps;
float laneLeft = LaneLeft;
float laneWidth = 0f;
for (int i = 0; i < steps; i++) laneWidth += CellW + CellMargin(i, steps);
float totalH = RulerH + p.Tracks.Count * (CellH + RowGap);
var rack = new Container
{
Key = "rack",
Position = PositionMode.Relative,
FlexDirection = FlexDirection.Column,
Children = { BuildRuler(steps, laneLeft) },
};
for (int r = 0; r < p.Tracks.Count; r++)
rack.Children.Add(BuildRow(p, p.Tracks[r], steps));
// sweeping playhead bar across the cells region
float head = Math.Clamp(p.PlayheadFraction, 0f, 1f);
rack.Children.Add(new Container
{
Key = "playhead",
Position = PositionMode.Absolute,
Left = laneLeft + head * laneWidth,
Top = 0,
Width = 2f,
Height = totalH,
BackgroundColor = Hot,
PointerEvents = PointerEvents.None,
});
// Seat the rack on an Ink LCD panel (matches the scope) so it reads as a screen,
// not loose cells on the chassis. Padding insets the content from the border; the
// playhead stays aligned because it is absolute within `rack` (the inner content box).
return new Container
{
Key = "racklcd",
BackgroundColor = Ink,
BorderColor = Ink,
BorderWidth = OutlineWidth,
BorderRadius = ScopeRadius,
PaddingLeft = Pad, PaddingTop = Pad, PaddingRight = Pad, PaddingBottom = Pad,
Children = { rack },
};
}
static Container BuildRuler(int steps, float laneLeft)
{
var ruler = new Container
{
Key = "ruler",
FlexDirection = FlexDirection.Row,
Height = RulerH,
Children =
{
new Container { Key = "rspacer", Width = laneLeft, PointerEvents = PointerEvents.None },
},
};
for (int i = 0; i < steps; i++)
{
ruler.Children.Add(new Container
{
Key = $"tick-{i}",
Width = CellW,
MarginRight = CellMargin(i, steps),
AlignItems = Align.Center,
PointerEvents = PointerEvents.None,
Children =
{
new Text(i % 4 == 0 ? (i + 1).ToString() : "")
{
FontSize = 8f, FontColor = Paper.WithAlpha(0.5f), FontFamily = FontMono,
},
},
});
}
return ruler;
}
// S/M/X control button: lit (Hot fill, Ink glyph) when active, else Paper outline.
static Container HeadButton(string key, string glyph, bool active, float marginRight, Action<MousePanelEvent> onDown)
=> new Container
{
Key = key,
Width = BtnW, Height = BtnW,
MarginRight = marginRight,
BorderColor = active ? Hot : Paper, BorderWidth = 1.5f, BorderRadius = 3f,
BackgroundColor = active ? Hot : Color.Transparent,
JustifyContent = Justify.Center, AlignItems = Align.Center,
OnMouseDown = onDown,
Children =
{
new Text(glyph) { FontSize = 9f, FontColor = active ? Ink : Paper, FontFamily = FontLabel },
},
};
static Container BuildRow(Props p, Track t, int steps)
{
var row = new Container
{
Key = $"trk-{t.Pad}",
Position = PositionMode.Relative,
FlexDirection = FlexDirection.Row,
AlignItems = Align.Center,
Height = CellH,
MarginBottom = RowGap,
Children =
{
new Container
{
Key = "lbl",
Width = LabelW,
MarginRight = LabelGap,
PointerEvents = PointerEvents.None,
Children =
{
new Text(t.Label) { FontSize = 9f, FontColor = Paper, FontFamily = FontLabel },
},
},
HeadButton("solo", "S", t.Soloed, CtrlGap, p.TrackSolo[t.Pad]),
HeadButton("mute", "M", t.Muted, CtrlGap, p.TrackMute[t.Pad]),
HeadButton("del", "X", false, HeadGap, p.TrackDelete[t.Pad]),
},
};
// A silenced track (muted, or excluded by another track's solo) renders dim.
Color onColor = t.Silenced ? Hot.WithAlpha(0.30f) : Hot;
Color onBg = t.Silenced ? Hot.WithAlpha(0.18f) : Hot;
for (int s = 0; s < steps; s++)
{
bool on = s < t.Occupied.Length && t.Occupied[s];
row.Children.Add(new Container
{
Key = $"cell-{s}",
Width = CellW, Height = CellH,
MarginRight = CellMargin(s, steps),
BorderColor = on ? onColor : Paper.WithAlpha(0.30f),
BorderWidth = 1.5f,
BorderRadius = 2f,
BackgroundColor = on ? onBg : Paper.WithAlpha(0.08f),
OnMouseDown = p.CellClick[(s, t.Pad)],
});
}
return row;
}
}