You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Domain widgets Tier 3: add ChannelFader (vertical fader with dB taper and a meter beside it) #528
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 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
New:
ImGui.Widgets/ChannelFader.cs: the widget, and internal static class ChannelFaderImpl with a per-id Dictionary<uint, ChannelFaderState>.
ImGui.Widgets/FaderTaper.cs: public, no ImGui.
ImGui.Widgets/ChannelFaderState.cs: internal, no ImGui.
tests/ImGui.Widgets.Tests/ChannelFaderStateTests.cs: covers FaderTaper and ChannelFaderState.
tests/ImGui.Widgets.UITests/ChannelFaderTests.cs
examples/ImGuiWidgetsDemo/ChannelFaderDemo.cs
Changed:
ImGui.Widgets/MeterScale.cs:
Adds the DrawVerticalMeter overload below that takes a dbToFraction mapping.
tests/ImGuiWidgetsDemo.UITests/WidgetsDemoUITests.cs: add "Channel Fader" to WidgetDemoSections.
CLAUDE.md: in the ImGui.Widgets line's "input and controls" group, add ChannelFader/FaderTaper after RangeSlider.
ImGui.Widgets/README.md: add a ChannelFader bullet under "### Input and Controls".
Public API
namespacektsu.ImGui.Widgets;publicstaticpartialclassImGuiWidgets{/// <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>publicstaticboolChannelFader(stringlabel,reffloatgainDb,floatmeterDb=float.NegativeInfinity,floatpeakDb=float.NegativeInfinity,Vector2size=default,floatmaxDb=6f);/// <summary>The console fader taper shared by <see cref="ChannelFader"/> and its meter. Position 0 is the bottom stop, 1 the top.</summary>publicstaticclassFaderTaper{/// <summary>Position of unity gain (0 dB): 0.75.</summary>publicconstfloatUnityPosition=0.75f;/// <summary>The lowest finite level on the scale; anything quieter is -inf. -60.</summary>publicconstfloatFloorDb=-60f;/// <summary>Maps a travel position in [0, 1] to dB. 0 returns float.NegativeInfinity.</summary>publicstaticfloatPositionToDb(floatposition,floatmaxDb=6f);/// <summary>Maps dB to a travel position in [0, 1]. The exact inverse of <see cref="PositionToDb"/> on (0, 1].</summary>publicstaticfloatDbToPosition(floatdb,floatmaxDb=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>publicstaticfloatNudge(floatdb,floatnotches,floatstepDb,floatmaxDb=6f);}}internalsealedclassChannelFaderState{/// <summary>True between a press on the fader track and Release.</summary>publicboolIsDragging{get;}/// <summary>Handle position minus pointer position at press time; 0 when the press jumped.</summary>publicfloatGrabOffset{get;}/// <summary>Starts a drag. A press within <paramref name="grabHalfExtent"/> of the handle keeps the offset; any other press jumps.</summary>publicvoidPress(floathandlePosition,floatpointerPosition,floatgrabHalfExtent);/// <summary>Moves the single handle in <paramref name="position"/> (length 1) to pointer + GrabOffset, clamped to [0, 1]. True if it moved.</summary>publicboolDrag(Span<float>position,floatpointerPosition);/// <summary>Ends the drag.</summary>publicvoidRelease();}internalstaticclassMeterScale{/// <summary>Draws the meter body with an arbitrary level-to-height mapping; zone colour is still chosen from the dB value.</summary>publicstaticvoidDrawVerticalMeter(ImDrawListPtrdrawList,Vector2min,Vector2max,floatdb,floatpeakDb,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:
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).
PositionToDb clamps position to [0, 1] first, and NaN is treated as UnityPosition. PositionToDb(0) is float.NegativeInfinity.
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].
maxDb is clamped to [1, 24] in all three methods, and NaN is treated as 6. Only the top segment depends on it.
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.
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.
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.
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):
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.
The track: a vertical line at the column centre, max(frameHeight * 0.18, 2) thick, in ImGuiCol.FrameBg.
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.
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.
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).
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.
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.
When the left button is not active: state.Release().
ImGui.IsItemClicked(ImGuiMouseButton.Right) resets gainDb to 0. It returns true only if gainDb was not already 0.
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.
Tooltip while hovered and not dragging: the visible part of label (via the existing VisibleLabel helper), a newline, and the readout text.
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.
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.
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
DbMeternext to aVSliderFloat. Mixers, bus strips and any per-channel gain control use it.Reuse:
HandleTrackState, the same oneRangeSlideruses, with a single handle on a 0..1 position track, plus a grab offset so pressing the cap never jumps it.MeterScalefrom Domain widgets Tier 3: add StereoMeters (gain-reduction, correlation and goniometer meters) #524 (StereoMeters), which is extracted fromDbMeter.FaderTaper, so a host can convert automation data or a MIDI controller position with exactly the numbers the widget uses.Files
ImGui.Widgets/ChannelFader.cs: the widget, andinternal static class ChannelFaderImplwith a per-idDictionary<uint, ChannelFaderState>.ImGui.Widgets/FaderTaper.cs: public, no ImGui.ImGui.Widgets/ChannelFaderState.cs: internal, no ImGui.tests/ImGui.Widgets.Tests/ChannelFaderStateTests.cs: coversFaderTaperandChannelFaderState.tests/ImGui.Widgets.UITests/ChannelFaderTests.csexamples/ImGuiWidgetsDemo/ChannelFaderDemo.csImGui.Widgets/MeterScale.cs:DrawVerticalMeteroverload below that takes adbToFractionmapping.db => DbToFraction(db, minDb, maxDb), soDbMeterstays pixel-identical.MeterScaleexactly as Domain widgets Tier 3: add StereoMeters (gain-reduction, correlation and goniometer meters) #524 specifies (including movingDbMeteronto it), and Domain widgets Tier 3: add StereoMeters (gain-reduction, correlation and goniometer meters) #524 then only adds its meters.examples/ImGuiWidgetsDemo/SignalDemos.cs: callChannelFaderDemo.Show()/ResetState().tests/ImGuiWidgetsDemo.UITests/WidgetsDemoUITests.cs: add"Channel Fader"toWidgetDemoSections.CLAUDE.md: in the ImGui.Widgets line's "input and controls" group, addChannelFader/FaderTaperafterRangeSlider.ImGui.Widgets/README.md: add aChannelFaderbullet under "### Input and Controls".Public API
gainDbis 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 byImGui.GetID(label), asRangeSliderkeys itsHandleTrackState.State class
There are two state classes,
FaderTaper(public, static) andChannelFaderState(internal). Neither has an ImGui dependency.Taper breakpoints for
maxDb= M, as (position, dB). Between consecutive breakpoints the dB value is linear in position.Rules:
PositionToDb(p) = -60 + 20 * log10(p / 0.05). This joins the −60 breakpoint continuously and reaches −∞ only at exactly 0. Inside the segmentDbToPosition(db) = 0.05 * 10^((db + 60) / 20).PositionToDbclampspositionto [0, 1] first, and NaN is treated asUnityPosition.PositionToDb(0)isfloat.NegativeInfinity.DbToPositionmaps −∞ to 0 and anything ≥ M to 1. NaN is treated as 0 dB, which gives 0.75. It is strictly increasing, andPositionToDb(DbToPosition(db))returnsdbwithin 1e-4 for every finitedbin [−120, M].maxDbis clamped to [1, 24] in all three methods, and NaN is treated as 6. Only the top segment depends on it.Nudge:notchesof 0 or NaN returnsdbunchanged.FloorDb; a downward nudge stays at −∞.db + notches * stepDb. A result belowFloorDbbecomes −∞, and a result above M is clamped to M.ChannelFaderState.Press:|pointer − handle| <= grabHalfExtent, thenGrabOffset = handle − pointer. OtherwiseGrabOffset = 0, so the press jumps.HandleTrackStateon the one-element span and setsIsDragging.DragreturnsHandleTrackState.Drag(position, pointer + GrabOffset, 0, 1, 0), and it returns false unlessIsDragging. A drag that resolves to the same position returns false, which isHandleTrackState's rule.ReleaseclearsIsDragging, zeroesGrabOffsetand releases the inner state.Drawing and interaction
Geometry.
sizeis the body, and a readout line sits under it.round(size.X * 0.6)px. The gap isstyle.ItemInnerSpacing.X. The meter column is the rest, with a minimum of 4 px.faderWidth * 0.8wide andmax(frameHeight * 0.6, 8)tall.trackTop = body.Min.Y + capHeight / 2totrackBottom = body.Max.Y - capHeight / 2. Position 1 is attrackTop.trackTop..trackBottom, so every height means the same dB level in both columns.Reservation and probes.
ImGui.InvisibleButton(label, new Vector2(size.X, size.Y + style.ItemInnerSpacing.Y + ImGui.GetTextLineHeight()), ImGuiButtonFlags.MouseButtonLeft | ImGuiButtonFlags.MouseButtonRight), thenImGuiProbes.MarkItem(label).MarkRegion($"{label}/track", …)for the fader column body.MarkRegion($"{label}/handle", …)for the cap rect.MarkRegion($"{label}/meter", …)for the meter rect, fromtrackToptotrackBottom.Draw order (window draw list):
ImGuiCol.Border, 1 px, atM, 0, −10, −20, −40 and −60. Each is placed atFaderTaper.DbToPosition. Most span the left 30% of the fader column. The 0 dB tick spans the full fader column width.max(frameHeight * 0.18, 2)thick, inImGuiCol.FrameBg.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.style.GrabRounding, inImGuiCol.SliderGrabActivewhile hovered or active andImGuiCol.SliderGrabotherwise.ImGuiCol.Text, marking where the gain is read.body.Max.Y + ItemInnerSpacing.Y, inImGuiCol.Text. It shows"{0:+0.0;-0.0;0.0} dB", or"-inf dB"whengainDbis −∞.Interaction, each frame. Let
pointer = clamp((trackBottom − mouseY) / (trackBottom − trackTop), 0, 1).IsItemActivated()with the left button, when the mouse is inside thetrackregion:state.Press(DbToPosition(gainDb), pointer, (capHeight / 2) / trackSpan).A left press anywhere else (the meter, the readout) starts nothing.
IsItemActive()with the left button held andstate.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.
state.Release().ImGui.IsItemClicked(ImGuiMouseButton.Right)resetsgainDbto 0. It returns true only ifgainDbwas not already 0.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-zeroio.MouseWheelthen appliesgainDb = FaderTaper.Nudge(gainDb, io.MouseWheel, io.KeyCtrl ? 0.1f : 1f, maxDb)and returns true if the value changed.label(via the existingVisibleLabelhelper), a newline, and the readout text.Edge cases
gainDbNaN is treated as 0 dB for drawing. It is written back only when an interaction changes it.gainDbabovemaxDb: the cap is drawn at the top. It is not rewritten until the user moves it.gainDbof −∞: the cap sits at the bottom stop, and one wheel notch up gives −60.sizewith a zero, negative or NaN component: the default is used, per component.maxDboutside [1, 24] is clamped, and NaN is treated as 6.meterDborpeakDbNaN: no fill, or no peak line.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_ZeroIsNegativeInfinityPositionToDb_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 andPositionToDb(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_ZeroOrNaNNotchesLeaveTheValuePress_OnTheCapKeepsTheGrabOffset: handle 0.75, pointer 0.77, half-extent 0.05.Dragto pointer 0.77 returns false and the position stays 0.75.Dragto 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.GrabOffsetis 0, andDragto 0.2 returns true with position 0.2.Drag_ClampsToTheTrack: press on the cap at 0.95 with offset 0.02,Dragto pointer 1.2 → position 1.Dragto −3 → position 0.Drag_WithoutAPressIsFalseRelease_StopsTheDrag: afterRelease,Dragreturns false andGrabOffsetis 0.UI isolation tests (
tests/ImGui.Widgets.UITests/ChannelFaderTests.cs, deriving fromWidgetTest)Setup:
"fader", size (60, 240).float gainDb = 0fandfloat meterDb = float.NegativeInfinity.changed |= ImGuiWidgets.ChannelFader("fader", ref gainDb, meterDb, size: new Vector2(60, 240)).Markcalls.Tests:
ChannelFader_MarksItselfAndItsParts:IsVisibleis 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 ofmeter.Bottom − 0.75 * meter.Height, wheremeter = RectOf("fader/meter").ChannelFader_PressOnTheCapDoesNotMoveIt:Click("fader/handle")leaveschangedfalse andgainDbexactly 0.ChannelFader_DragDownLowersGain: drag fromCenterOf("fader/handle")straight down 40 px.changedis true, andgainDbequalsFaderTaper.PositionToDb(0.75 − 40 / meter.Height)within 0.5 dB.ChannelFader_ClickLowOnTheTrackJumps:ClickFraction("fader/track", 0.5, 0.97)makesgainDb< −40 andchangedtrue.ChannelFader_DragBelowTheBottomReachesNegativeInfinity: drag the cap to 100 px below the widget, andgainDbbecomesfloat.NegativeInfinity.ChannelFader_RightClickResetsToUnity: with gain −12, a right-click atCenterOf("fader/track")(viaHarness.Mouse.Click(x, y, 1)) setsgainDbto 0 andchangedto true.ChannelFader_WheelNudgesByOneDb:Harness.Mouse.Wheel(center of handle, 1)changesgainDbfrom 0 to 1.ChannelFader_MeterFillsToTheTaperedHeight: take a snapshot atmeterDb−∞, then setmeterDb = -10andStep(2). TheBoundsOfDifferencelies insideRectOf("fader/meter"), and its top is within 2 px ofmeter.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 throughDemoProbe."Ch 1"–"Ch 3"and"Master"via##ids.sourceDb + gainDb. The sources are −6, −12 and −18 dB, each wobbling ±3 dB at a different rate.ResetState()puts every fader back to 0 dB.Out of scope
Knoband buttons.maxDb.Done when
ChannelFader,FaderTaperandChannelFaderStateexist as specified. The widget callsImGuiProbes.MarkItem(label)and markstrack,handleandmeter.HandleTrackState, andMeterScalegains thedbToFractionoverload withDbMeterstill drawing pixel-identically (existingDbMeterTestsunedited and passing).ChannelFaderStateTestsandChannelFaderTestspass.WidgetDemoSections, and the widgets demo UI tests pass.CLAUDE.mdandImGui.Widgets/README.mdlistChannelFader/FaderTaper.// Copyright (c) 2023-2026 ktsu-dev contributorsheader and follows the style: tabs, explicit types, file-scoped namespaces.