Skip to content

Domain widgets Tier 3: add ChannelFader (vertical fader with dB taper and a meter beside it) #528

Description

@matt-edmondson

Part of the domain widget shortlist (docs/plans/2026-09-29-domain-widget-shortlist.md), Tier 3, split out of #512.

Summary

A mixing-console channel fader: a vertical fader with a console-style dB taper and a level meter beside it. Unity (0 dB) sits three quarters of the way up. The scale is stretched around the working range and compressed towards −∞ at the bottom. The meter uses the same taper, so a signal at −10 dB lights the meter to exactly the height of the −10 tick the cap would sit on. That alignment is the reason for drawing the two together rather than placing a DbMeter next to a VSliderFloat. Mixers, bus strips and any per-channel gain control use it.

Reuse:

  • The drag engine is HandleTrackState, the same one RangeSlider uses, with a single handle on a 0..1 position track, plus a grab offset so pressing the cap never jumps it.
  • The meter draws through MeterScale from Domain widgets Tier 3: add StereoMeters (gain-reduction, correlation and goniometer meters) #524 (StereoMeters), which is extracted from DbMeter.
  • The taper is a public, context-free FaderTaper, so a host can convert automation data or a MIDI controller position with exactly the numbers the widget uses.

Files

Public API

namespace ktsu.ImGui.Widgets;

public static partial class ImGuiWidgets
{
	/// <summary>Draws a vertical channel fader with a console dB taper and a tapered level meter beside it.</summary>
	/// <param name="label">ImGui id and probe name. Parts are probed as "{label}/track", "{label}/handle", "{label}/meter".</param>
	/// <param name="gainDb">Fader gain in dB, edited in place. float.NegativeInfinity is the bottom stop (-inf, silence).</param>
	/// <param name="meterDb">Level to show on the meter, in dB; -inf (the default) shows an empty meter.</param>
	/// <param name="peakDb">Optional held peak for the meter, in dB; -inf (the default) draws none.</param>
	/// <param name="size">Size of the fader-plus-meter body; <c>default</c> means (2.5 x frame height, 10 x text line height). A one-line dB readout is reserved beneath it.</param>
	/// <param name="maxDb">Gain at the top of the travel. Default 6. Clamped to [1, 24].</param>
	/// <returns><see langword="true"/> on a frame in which <paramref name="gainDb"/> changed (drag, track click, wheel or right-click reset).</returns>
	public static bool ChannelFader(string label, ref float gainDb, float meterDb = float.NegativeInfinity, float peakDb = float.NegativeInfinity, Vector2 size = default, float maxDb = 6f);

	/// <summary>The console fader taper shared by <see cref="ChannelFader"/> and its meter. Position 0 is the bottom stop, 1 the top.</summary>
	public static class FaderTaper
	{
		/// <summary>Position of unity gain (0 dB): 0.75.</summary>
		public const float UnityPosition = 0.75f;

		/// <summary>The lowest finite level on the scale; anything quieter is -inf. -60.</summary>
		public const float FloorDb = -60f;

		/// <summary>Maps a travel position in [0, 1] to dB. 0 returns float.NegativeInfinity.</summary>
		public static float PositionToDb(float position, float maxDb = 6f);

		/// <summary>Maps dB to a travel position in [0, 1]. The exact inverse of <see cref="PositionToDb"/> on (0, 1].</summary>
		public static float DbToPosition(float db, float maxDb = 6f);

		/// <summary>Steps <paramref name="db"/> by <paramref name="notches"/> x <paramref name="stepDb"/>, falling to -inf below the floor and clamped to maxDb.</summary>
		public static float Nudge(float db, float notches, float stepDb, float maxDb = 6f);
	}
}

internal sealed class ChannelFaderState
{
	/// <summary>True between a press on the fader track and Release.</summary>
	public bool IsDragging { get; }

	/// <summary>Handle position minus pointer position at press time; 0 when the press jumped.</summary>
	public float GrabOffset { get; }

	/// <summary>Starts a drag. A press within <paramref name="grabHalfExtent"/> of the handle keeps the offset; any other press jumps.</summary>
	public void Press(float handlePosition, float pointerPosition, float grabHalfExtent);

	/// <summary>Moves the single handle in <paramref name="position"/> (length 1) to pointer + GrabOffset, clamped to [0, 1]. True if it moved.</summary>
	public bool Drag(Span<float> position, float pointerPosition);

	/// <summary>Ends the drag.</summary>
	public void Release();
}

internal static class MeterScale
{
	/// <summary>Draws the meter body with an arbitrary level-to-height mapping; zone colour is still chosen from the dB value.</summary>
	public static void DrawVerticalMeter(ImDrawListPtr drawList, Vector2 min, Vector2 max, float db, float peakDb, Func<float, float> dbToFraction);
}

gainDb is the caller's value. The widget writes it only on a frame where it returns true. On every other frame it is left bit-identical, so a stored −3.1 dB never drifts by a round trip through the taper. The drag state is internal and keyed by ImGui.GetID(label), as RangeSlider keys its HandleTrackState.

State class

There are two state classes, FaderTaper (public, static) and ChannelFaderState (internal). Neither has an ImGui dependency.

Taper breakpoints for maxDb = M, as (position, dB). Between consecutive breakpoints the dB value is linear in position.

position 0.05 0.15 0.30 0.50 0.75 1.00
dB −60 −40 −20 −10 0 M

Rules:

  1. Below 0.05, PositionToDb(p) = -60 + 20 * log10(p / 0.05). This joins the −60 breakpoint continuously and reaches −∞ only at exactly 0. Inside the segment DbToPosition(db) = 0.05 * 10^((db + 60) / 20).
  2. PositionToDb clamps position to [0, 1] first, and NaN is treated as UnityPosition. PositionToDb(0) is float.NegativeInfinity.
  3. DbToPosition maps −∞ to 0 and anything ≥ M to 1. NaN is treated as 0 dB, which gives 0.75. It is strictly increasing, and PositionToDb(DbToPosition(db)) returns db within 1e-4 for every finite db in [−120, M].
  4. maxDb is clamped to [1, 24] in all three methods, and NaN is treated as 6. Only the top segment depends on it.
  5. Nudge:
    • notches of 0 or NaN returns db unchanged.
    • From −∞, any upward nudge returns exactly FloorDb; a downward nudge stays at −∞.
    • Otherwise it returns db + notches * stepDb. A result below FloorDb becomes −∞, and a result above M is clamped to M.
  6. ChannelFaderState.Press:
    • If |pointer − handle| <= grabHalfExtent, then GrabOffset = handle − pointer. Otherwise GrabOffset = 0, so the press jumps.
    • It activates its inner HandleTrackState on the one-element span and sets IsDragging.
  7. Drag returns HandleTrackState.Drag(position, pointer + GrabOffset, 0, 1, 0), and it returns false unless IsDragging. A drag that resolves to the same position returns false, which is HandleTrackState's rule.
  8. Release clears IsDragging, zeroes GrabOffset and releases the inner state.

Drawing and interaction

Geometry. size is the body, and a readout line sits under it.

  • The fader column is the left round(size.X * 0.6) px. The gap is style.ItemInnerSpacing.X. The meter column is the rest, with a minimum of 4 px.
  • The cap is faderWidth * 0.8 wide and max(frameHeight * 0.6, 8) tall.
  • The track runs down the fader column's centre from trackTop = body.Min.Y + capHeight / 2 to trackBottom = body.Max.Y - capHeight / 2. Position 1 is at trackTop.
  • The meter occupies the same vertical span, trackTop..trackBottom, so every height means the same dB level in both columns.

Reservation and probes.

  • One ImGui.InvisibleButton(label, new Vector2(size.X, size.Y + style.ItemInnerSpacing.Y + ImGui.GetTextLineHeight()), ImGuiButtonFlags.MouseButtonLeft | ImGuiButtonFlags.MouseButtonRight), then ImGuiProbes.MarkItem(label).
  • Then these regions:
    • MarkRegion($"{label}/track", …) for the fader column body.
    • MarkRegion($"{label}/handle", …) for the cap rect.
    • MarkRegion($"{label}/meter", …) for the meter rect, from trackTop to trackBottom.

Draw order (window draw list):

  1. Ticks in ImGuiCol.Border, 1 px, at M, 0, −10, −20, −40 and −60. Each is placed at FaderTaper.DbToPosition. Most span the left 30% of the fader column. The 0 dB tick spans the full fader column width.
  2. The track: a vertical line at the column centre, max(frameHeight * 0.18, 2) thick, in ImGuiCol.FrameBg.
  3. The meter: MeterScale.DrawVerticalMeter(drawList, meterMin, meterMax, meterDb, peakDb, db => FaderTaper.DbToPosition(db, maxDb)). This draws FrameBg, a zone-coloured fill from the bottom, the peak line and the Border.
  4. The cap:
    • A filled rect, rounded by style.GrabRounding, in ImGuiCol.SliderGrabActive while hovered or active and ImGuiCol.SliderGrab otherwise.
    • A 1 px horizontal centre line across it in ImGuiCol.Text, marking where the gain is read.
  5. The readout, centred horizontally under the body at body.Max.Y + ItemInnerSpacing.Y, in ImGuiCol.Text. It shows "{0:+0.0;-0.0;0.0} dB", or "-inf dB" when gainDb is −∞.

Interaction, each frame. Let pointer = clamp((trackBottom − mouseY) / (trackBottom − trackTop), 0, 1).

  1. IsItemActivated() with the left button, when the mouse is inside the track region: state.Press(DbToPosition(gainDb), pointer, (capHeight / 2) / trackSpan).
    A left press anywhere else (the meter, the readout) starts nothing.
  2. IsItemActive() with the left button held and state.IsDragging: state.Drag(position, pointer).
    If it returns true, gainDb = PositionToDb(position[0], maxDb) and the widget returns true.
    A press off the cap jumps and drags in the same frame.
  3. When the left button is not active: state.Release().
  4. ImGui.IsItemClicked(ImGuiMouseButton.Right) resets gainDb to 0. It returns true only if gainDb was not already 0.
  5. While hovered: ImGui.SetItemKeyOwner(ImGuiKey.MouseWheelY) claims the wheel, the same way Domain widgets Tier 2: add ParametricEq (draggable bands over the response curve) #505 claims it. A non-zero io.MouseWheel then applies gainDb = FaderTaper.Nudge(gainDb, io.MouseWheel, io.KeyCtrl ? 0.1f : 1f, maxDb) and returns true if the value changed.
  6. Tooltip while hovered and not dragging: the visible part of label (via the existing VisibleLabel helper), a newline, and the readout text.
  7. There is no keyboard control, and the cursor is left unchanged.

Edge cases

  • gainDb NaN is treated as 0 dB for drawing. It is written back only when an interaction changes it.
  • gainDb above maxDb: the cap is drawn at the top. It is not rewritten until the user moves it.
  • gainDb of −∞: the cap sits at the bottom stop, and one wheel notch up gives −60.
  • size with a zero, negative or NaN component: the default is used, per component.
  • maxDb outside [1, 24] is clamped, and NaN is treated as 6.
  • meterDb or peakDb NaN: no fill, or no peak line.
  • A zero-length track can only happen when the body is shorter than the cap. The span is floored at 1 px so the pointer mapping never divides by zero.
  • A drag leaving the widget keeps dragging and clamps at the stops, because the button stays active.

Tests

Unit tests (tests/ImGui.Widgets.Tests/ChannelFaderStateTests.cs)

  • PositionToDb_Breakpoints: 1 → 6, 0.75 → 0, 0.5 → −10, 0.3 → −20, 0.15 → −40 and 0.05 → −60, each exact within 1e-5.
  • PositionToDb_InterpolatesLinearlyBetweenBreakpoints: 0.625 → −5 and 0.4 → −15.
  • PositionToDb_ZeroIsNegativeInfinity
  • PositionToDb_BelowTheFloorIsLogarithmic: 0.025 → −66.0206 within 1e-3.
  • PositionToDb_ClampsAndTreatsNaNAsUnity: −1 → −∞, 2 → 6, NaN → 0.
  • PositionToDb_MaxDbMovesOnlyTheTopSegment: with maxDb 10, 0.875 → 5 and 0.5 → −10.
  • DbToPosition_InvertsPositionToDb: for 1000 evenly spaced positions in (0, 1], the round trip is within 1e-5.
  • DbToPosition_IsStrictlyIncreasing: across −120..6 in 0.1 dB steps, each position is greater than the previous one.
  • DbToPosition_EndsAndNaN: −∞ → 0, 20 → 1, NaN → 0.75.
  • MaxDb_IsClampedToOneThroughTwentyFour: PositionToDb(1, 0.2f) → 1 and PositionToDb(1, 100) → 24.
  • Nudge_StepsByOneDb: (0, 1, 1) → 1 and (0, −3, 1) → −3.
  • Nudge_ClampsToMaxDb: (6, 1, 1) → 6.
  • Nudge_FallsToNegativeInfinityBelowTheFloor: (−59.5, −1, 1) → −∞.
  • Nudge_UpFromNegativeInfinityLandsOnTheFloor: (−∞, 1, 1) → −60. (−∞, −1, 1) → −∞.
  • Nudge_ZeroOrNaNNotchesLeaveTheValue
  • Press_OnTheCapKeepsTheGrabOffset: handle 0.75, pointer 0.77, half-extent 0.05. Drag to pointer 0.77 returns false and the position stays 0.75. Drag to 0.67 returns true and the position is 0.65 within 1e-6.
  • Press_OffTheCapJumps: handle 0.75, pointer 0.2, half-extent 0.05. GrabOffset is 0, and Drag to 0.2 returns true with position 0.2.
  • Drag_ClampsToTheTrack: press on the cap at 0.95 with offset 0.02, Drag to pointer 1.2 → position 1. Drag to −3 → position 0.
  • Drag_WithoutAPressIsFalse
  • Release_StopsTheDrag: after Release, Drag returns false and GrabOffset is 0.

UI isolation tests (tests/ImGui.Widgets.UITests/ChannelFaderTests.cs, deriving from WidgetTest)

Setup:

  • Label "fader", size (60, 240).
  • Fields float gainDb = 0f and float meterDb = float.NegativeInfinity.
  • changed |= ImGuiWidgets.ChannelFader("fader", ref gainDb, meterDb, size: new Vector2(60, 240)).
  • No Mark calls.

Tests:

  • ChannelFader_MarksItselfAndItsParts: IsVisible is true for "fader", "fader/track", "fader/handle" and "fader/meter".
  • ChannelFader_ReservesTheBodyAndAReadoutLine: RectOf("fader") width is within 2 px of 60, and its height is greater than 240 and at most 240 + 2 × line height.
  • ChannelFader_HandleSitsAtUnityForZeroDb: the handle's centre y is within 2 px of meter.Bottom − 0.75 * meter.Height, where meter = RectOf("fader/meter").
  • ChannelFader_PressOnTheCapDoesNotMoveIt: Click("fader/handle") leaves changed false and gainDb exactly 0.
  • ChannelFader_DragDownLowersGain: drag from CenterOf("fader/handle") straight down 40 px. changed is true, and gainDb equals FaderTaper.PositionToDb(0.75 − 40 / meter.Height) within 0.5 dB.
  • ChannelFader_ClickLowOnTheTrackJumps: ClickFraction("fader/track", 0.5, 0.97) makes gainDb < −40 and changed true.
  • ChannelFader_DragBelowTheBottomReachesNegativeInfinity: drag the cap to 100 px below the widget, and gainDb becomes float.NegativeInfinity.
  • ChannelFader_RightClickResetsToUnity: with gain −12, a right-click at CenterOf("fader/track") (via Harness.Mouse.Click(x, y, 1)) sets gainDb to 0 and changed to true.
  • ChannelFader_WheelNudgesByOneDb: Harness.Mouse.Wheel(center of handle, 1) changes gainDb from 0 to 1.
  • ChannelFader_MeterFillsToTheTaperedHeight: take a snapshot at meterDb −∞, then set meterDb = -10 and Step(2). The BoundsOfDifference lies inside RectOf("fader/meter"), and its top is within 2 px of meter.Bottom − 0.5 * meter.Height, which is where the fader's −10 tick is.

Demo

The demo is examples/ImGuiWidgetsDemo/ChannelFaderDemo.cs, in a section headed "Channel Fader" and marked through DemoProbe.

  • Four faders on one line, labelled "Ch 1"–"Ch 3" and "Master" via ## ids.
  • Each channel meters a demo sine whose level is sourceDb + gainDb. The sources are −6, −12 and −18 dB, each wobbling ±3 dB at a different rate.
  • The master meters the power sum of the three channels plus its own gain.
  • Each meter has a peak held for 1.5 s.
  • A line underneath prints every gain value, so wheel and reset changes are visible.
  • ResetState() puts every fader back to 0 dB.

Out of scope

  • A unity detent or snap while dragging, and fine-drag modifiers.
  • Keyboard control.
  • Double-click to reset. Right-click is the reset gesture, because double-click is unreliable to drive headlessly and a right-click on a fader has no other meaning.
  • Pan knobs, mute/solo buttons and channel-strip composition. Hosts compose those with the existing Knob and buttons.
  • Horizontal orientation, and custom taper tables. The taper is fixed apart from maxDb.
  • Stereo (two-bar) meters beside the fader.

Done when

  • ChannelFader, FaderTaper and ChannelFaderState exist as specified. The widget calls ImGuiProbes.MarkItem(label) and marks track, handle and meter.
  • The widget drags through HandleTrackState, and MeterScale gains the dbToFraction overload with DbMeter still drawing pixel-identically (existing DbMeterTests unedited and passing).
  • ChannelFaderStateTests and ChannelFaderTests pass.
  • The "Channel Fader" demo section is present and listed in WidgetDemoSections, and the widgets demo UI tests pass.
  • CLAUDE.md and ImGui.Widgets/README.md list ChannelFader/FaderTaper.
  • Every file carries the // Copyright (c) 2023-2026 ktsu-dev contributors header and follows the style: tabs, explicit types, file-scoped namespaces.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

readyFully specified; implement as written

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions