Custom widgets

ImGui core ships no rotary control; community ports vendor their own. This tutorial adds a rotary volume knob as a user-defined [widget] kind — the same annotation every built-in (button, slider_float, checkbox, …) uses. The knob plugs into pending_value_finalize unchanged: matching the slider state-struct convention is the only contract.

Source: examples/tutorial/custom_widgets.das.

Walkthrough

The recording drives the custom knob with real synthetic input and self-verifies. It drags the Master knob along its 270° arc (asserting value rose off 0), drags Treble up a little (same assertion), then drives Bass via imgui_force_set — the one place force_set is the subject, so it counts as the real input — and asserts the value took. Finally it clicks the ordinary Reset button and asserts Bass snapped from −0.5 back to its default 0, proving the live API and a plain built-in both operate on the custom widget unchanged. (The arc drag rides a radius-25 circle around the knob disc’s centre, which sits in the top third of the hitbox — bbox.x + 36, bbox.y + 36 — clear of the bottom dead zone.)

  1options gen2
  2// TODO: this tutorial uses pre-v2 raw imgui calls (Text/Spacing/Separator/
  3// SameLine etc.) and needs a follow-up rewrite to use the v2 wrappers
  4// (text/spacing/separator/same_line). Opt out of the default-on lint until
  5// that pass lands so the file stays compileable.
  6options _allow_imgui_legacy = true
  7
  8require imgui
  9require imgui_app
 10require opengl/opengl_boost
 11require live/glfw_live
 12require live/live_api
 13require live/live_commands
 14require live/live_vars
 15require live_host
 16require imgui/imgui_live
 17require imgui/imgui_boost_runtime
 18require imgui/imgui_boost_v2
 19require imgui/imgui_widgets_builtin
 20require imgui/imgui_containers_builtin
 21require imgui/imgui_visual_aids
 22require imgui/imgui_colors
 23
 24require math
 25require strings
 26
 27// =============================================================================
 28// TUTORIAL: custom_widgets — write your own widget kind with [widget].
 29//
 30// ImGui core ships no rotary control; here we add one. The knob:
 31//   - is a single [widget] def at module scope, ~30 lines including drawlist
 32//   - plugs into pending_value_finalize + one [widget_dispatch] — same
 33//     state-struct convention as slider_float, so imgui_snapshot reflects it
 34//     and imgui_force_set drives it, exactly like a built-in
 35//   - uses the canonical InvisibleButton + DrawList pattern for the body
 36//
 37// Read alongside :ref:`tutorial_widgets_tour` for the built-in catalog.
 38//
 39// STANDALONE: daslang.exe modules/dasImgui/examples/tutorial/custom_widgets.das
 40// LIVE:       daslang-live modules/dasImgui/examples/tutorial/custom_widgets.das
 41//
 42// DRIVE (when running live):
 43//   curl -X POST -d '{"name":"imgui_force_set","args":{"target":"MIXER_WIN/MASTER","value":0.75}}'   localhost:9090/command
 44//   curl -X POST -d '{"name":"imgui_force_set","args":{"target":"MIXER_WIN/TREBLE","value":-0.3}}'   localhost:9090/command
 45//   curl -X POST -d '{"name":"imgui_click","args":{"target":"MIXER_WIN/RESET_BTN"}}'           localhost:9090/command
 46// =============================================================================
 47
 48//! State struct for the knob — same five-field convention as ``SliderStateFloat``
 49//! so :ref:`pending_value_finalize <function-imgui_boost_runtime_pending_value_finalize>`
 50//! can drive it without modification.
 51struct VolumeKnobState {
 52    @live value : float                   //! current knob value, preserved across reload
 53    @live bounds : tuple<float; float>    //! (min, max) — set per-frame at the call site
 54    @optional has_pending : bool          //! imgui_force_set queued; consumed next frame
 55    @optional pending_value : float       //! next-frame value queued by imgui_force_set
 56    @optional changed : bool              //! true on the frame the widget fired
 57}
 58
 59// imgui_force_set's "set" action queues pending_value (drained in knob() step 1).
 60// The dispatch half of the custom-widget rail; pending_value_finalize is the other.
 61// payload is JSON (built by write_json), so parse it with sscan_json_at exactly
 62// like the built-in value dispatchers (pending_value_apply, imgui_widgets_builtin.das).
 63[widget_dispatch]
 64def knob_dispatch(var state : VolumeKnobState; action : string; payload : string) {
 65    if (action == "set") {
 66        unsafe {
 67            sscan_json_at(payload, addr(state.pending_value),
 68                          *reinterpret<TypeInfo const?>(typeinfo rtti_typeinfo(type<float>)))
 69        }
 70        state.has_pending = true
 71    }
 72}
 73
 74//! Rotary knob — vertical-drag value control with a 270° indicator arc.
 75//! ``widget_ident`` is injected at position 1 by the ``[widget]`` annotation;
 76//! pass it to ``pending_value_finalize`` at the bottom.
 77[widget]
 78def knob(var state : VolumeKnobState; text : string) : bool {
 79    // 1. Drain imgui_force_set: any pending dispatcher-side update lands here.
 80    if (state.has_pending) {
 81        state.value = state.pending_value
 82        state.has_pending = false
 83    }
 84    let (mn, mx) = state.bounds
 85
 86    // 2. Reserve a hitbox. InvisibleButton is the "registered" item that
 87    //    widget_finalize (via the [widget] postlude) attaches bbox/hex_id/
 88    //    hover/active/focus to. Hitbox = knob disc + label + value readout,
 89    //    so the whole rendered widget is one ImGui-layout cell of fixed
 90    //    width — adjacent knobs stay aligned regardless of value-text width.
 91    let p = GetCursorScreenPos()
 92    let sz = ImVec2(72.0f, 112.0f)             // 72×72 knob + 24 label + 16 value
 93    let radius = 30.0f
 94    let center = ImVec2(p.x + sz.x * 0.5f, p.y + 36.0f)
 95    InvisibleButton(text, sz)
 96
 97    // 3. Drag handling — mouse position relative to center drives the
 98    //    angle directly. atan2 returns (-π, π]; shifting by -3π/4 and
 99    //    wrapping into [0, 2π) puts the active arc into [0, 3π/2) and the
100    //    bottom-gap dead zone into [3π/2, 2π). Dead-zone clicks are
101    //    ignored — value holds at its previous reading.
102    var changed = false
103    if (IsItemActive()) {
104        let mp = GetIO().MousePos
105        var th = atan2(mp.y - center.y, mp.x - center.x) - 3.0f * PI / 4.0f
106        if (th < 0.0f) {
107            th += 2.0f * PI
108        }
109        if (th < 3.0f * PI / 2.0f) {
110            let f = th / (3.0f * PI / 2.0f)
111            let new_val = clamp(mn + f * (mx - mn), mn, mx)
112            if (new_val != state.value) {
113                state.value = new_val
114                changed = true
115            }
116        }
117    }
118    state.changed = changed
119
120    // 4. Draw via the window's drawlist. Indicator sweeps a 270° arc with
121    //    a 90° gap at the bottom (DAW convention):
122    //       frac = 0 → θ = 3π/4   (bottom-left, "7 o'clock")
123    //       frac = 0.5 → θ = 3π/2 (straight up, "12 o'clock")
124    //       frac = 1 → θ = 9π/4   (bottom-right, "5 o'clock")
125    //    ImGui's y-axis points down, so positive sin is down; the formula
126    //    is monotonic in θ and the inverse of step 3's mapping.
127    let frac = (state.value - mn) / (mx - mn)
128    let theta = 3.0f * PI / 4.0f + frac * 3.0f * PI / 2.0f
129    let tip = ImVec2(
130        center.x + cos(theta) * radius * 0.82f,
131        center.y + sin(theta) * radius * 0.82f
132    )
133    let hovered = IsItemHovered()
134    let rim_col = hovered ? rgba(190u, 200u, 220u, 255u) : rgba(120u, 130u, 150u, 255u)
135    *GetWindowDrawList() |> AddCircleFilled(center, radius, rgba(40u, 42u, 48u, 255u), 32)
136    *GetWindowDrawList() |> AddCircle(center, radius, rim_col, 32, 2.0f)
137    *GetWindowDrawList() |> AddLine(center, tip, rgba(220u, 200u, 60u, 255u), 3.0f)
138
139    // 5. Label + value readout, both drawlist (no ImGui layout impact).
140    let label_size = CalcTextSize(text, false, -1.0f)
141    let label_pos = ImVec2(center.x - label_size.x * 0.5f, p.y + 76.0f)
142    *GetWindowDrawList() |> AddText(label_pos, rgba(220u, 220u, 220u, 255u), text)
143    let val_str = build_string() <| $(var w) {
144        fmt(w, ":.2f", state.value)
145    }
146    let val_size = CalcTextSize(val_str, false, -1.0f)
147    let val_pos = ImVec2(center.x - val_size.x * 0.5f, p.y + 94.0f)
148    *GetWindowDrawList() |> AddText(val_pos, rgba(170u, 170u, 180u, 255u), val_str)
149
150    // 6. Register state + serializer (snapshot, and force_set draining). Same call
151    //    any slider makes; the [widget_dispatch] above routes the live "set" in.
152    pending_value_finalize(widget_ident, "knob", state)
153    return changed
154}
155
156[export]
157def init() {
158    live_create_window("dasImgui custom_widgets", 800, 520)
159    live_imgui_init(live_window)
160    let io & = unsafe(GetIO())
161    GetStyle().FontScaleMain = 1.5
162}
163
164[export]
165def update() {
166    if (!live_begin_frame()) return
167    begin_frame()
168
169    ImGui_ImplGlfw_NewFrame()
170    apply_synth_io_override()
171    NewFrame()
172
173    SetNextWindowPos(ImVec2(60.0f, 60.0f), ImGuiCond.Always)
174    SetNextWindowSize(ImVec2(680.0f, 380.0f), ImGuiCond.Always)
175    window(MIXER_WIN, (text = "Mastering", closable = false,
176                       flags = ImGuiWindowFlags.None)) {
177        Text("Three knobs from one [widget] def - different state globals.")
178        Spacing()
179
180        // Each knob is one ImGui-layout cell (fixed 72×112 hitbox), so
181        // SameLine spacing is constant regardless of value-text width.
182        MASTER.bounds = (0.0f, 1.0f)
183        knob(MASTER, (text = "Master"))
184        SameLine()
185        TREBLE.bounds = (-1.0f, 1.0f)
186        knob(TREBLE, (text = "Treble"))
187        SameLine()
188        BASS.bounds = (-1.0f, 1.0f)
189        knob(BASS, (text = "Bass"))
190
191        Spacing()
192        Separator()
193        Spacing()
194
195        // Ordinary built-in widget on the same panel — imgui_click works on it.
196        if (button(RESET_BTN, (text = "Reset"))) {
197            MASTER.value = 0.5f
198            TREBLE.value = 0.0f
199            BASS.value = 0.0f
200        }
201    }
202
203    end_of_frame()
204    Render()
205    var w, h : int
206    live_get_framebuffer_size(w, h)
207    glViewport(0, 0, w, h)
208    glClearColor(0.10f, 0.10f, 0.12f, 1.0f)
209    glClear(GL_COLOR_BUFFER_BIT)
210    live_imgui_render()
211
212    live_end_frame()
213}
214
215[export]
216def shutdown() {
217    live_imgui_shutdown()
218    live_destroy_window()
219}
220
221[export]
222def main() {
223    init()
224    while (!exit_requested()) {
225        update()
226    }
227    shutdown()
228}

Requires

Same boost stack as Widgets tour, with two additions: imgui/imgui_boost for IM_COL32 (the int/uint color helpers from the legacy boost layer), and math for cos / sin / PI used by the indicator angle.

The state struct

VolumeKnobState mirrors SliderStateFloat field-for-field:

struct VolumeKnobState {
    @live value : float
    @live bounds : tuple<float; float>
    @optional has_pending : bool
    @optional pending_value : float
    @optional changed : bool
}

This shape is the contract. pending_value_finalize is generic on the state type — it reads has_pending / pending_value to consume queued imgui_force_set deliveries, and serializes the whole struct (value, bounds, changed) verbatim into the snapshot. Any widget kind that matches these field names plugs straight into the rails. @live keeps value and bounds preserved across reloads; @optional lets the dispatcher-managed fields stay zero-defaulted in older saved states.

The [widget] annotation

The annotation does two things to the function it decorates (widgets/imgui_boost_v2.das:32):

  • Injects a ``widget_ident : string`` parameter at position 1, between state and the user-facing args. Inside the body, widget_ident is the bare identifier string ("MASTER" at the call site knob(MASTER, ...)) — pass it to pending_value_finalize so the finalizer can build the registry path.

  • Registers a per-kind ``WidgetCallMacro`` that intercepts knob(IDENT, ...) calls. The macro auto-emits the named global (MASTER) on first use, parses dotted-suffix flags (.PUBLIC / .NOTLIVE), and rewrites the call to thread widget_ident through.

The body also gets widget_prelude(widget_ident) injected at the top — that pushes the ImGui ID stack and applies any pending focus from imgui_focus. The user never calls it directly.

The drawlist pattern

Custom widgets follow the InvisibleButton + DrawList pattern from the boost design (API_REWORK.md §4.9). Sequence:

  1. InvisibleButton(text, sz) reserves a hitbox of size sz. This is the registered itemwidget_finalize reads bbox / hex_id / hover / active / focus from it, so it must be the last ImGui item before the finalizer call. Hitbox includes the label and value-readout area below the disc, so adjacent knobs stay aligned regardless of value-text width.

  2. While IsItemActive() is true, the body reads GetIO().MousePos and computes atan2(my - cy, mx - cx). The angle is shifted by -3π/4 and wrapped into [0, 2π) so the active arc lands in [0, 3π/2) and the bottom-gap dead zone in [3π/2, 2π). Dead-zone reads are ignored — value holds at its previous reading. InvisibleButton handles press / drag / release; the widget never has to track its own pressed state.

  3. GetWindowDrawList() returns the per-window draw list. Primitives — AddCircleFilled / AddCircle / AddLine / AddText — render inside the bbox but are pure painting; they don’t advance the ImGui cursor or participate in input.

Indicator angle is the inverse of the input mapping: θ = 3π/4 + frac · 3π/2 sweeps 270° with a 90° gap at the bottom (DAW convention). ImGui’s y-axis points down so positive sin θ is down on screen; the formula reads clockwise visually (frac=0 at 7 o’clock, frac=0.5 at 12, frac=1 at 5). Mouse position drives state.value which drives the indicator angle — no delta accumulation, no wraparound bookkeeping.

Reusing pending_value_finalize

The last line of the knob body — pending_value_finalize(widget_ident, "knob", state) — is the same line every value-typed built-in uses (slider_float, drag_float, input_float, color_edit3, combo). It builds the two finalize lambdas:

  • Serializer: closure over widget_ident, returns state_jv(path, type<VolumeKnobState>) — JSON-ifies the live state every time imgui_snapshot asks.

  • Dispatcher: closure that handles imgui_force_set with action "set" — writes state.pending_value and flips has_pending. Next frame the body drains it (step 1).

Then widget_finalize installs both lambdas keyed on the widget’s path, and register_focusable makes the widget reachable by imgui_focus.

For widgets that don’t fit this shape — pure-action buttons, multi-stage inputs, plots — the escape hatch is to write your own one-screen <kind>_finalize modeled on click_finalize, toggle_finalize, or plot_finalize in widgets/imgui_widgets_builtin.das. The shape is always the same: construct ser and disp lambdas via state_jv / with_state, then call widget_finalize.

Standalone vs live

main() runs the loop when invoked as daslang.exe custom_widgets.das. Under daslang-live the host calls init / update / shutdown directly; live-reloading the source preserves MASTER.value / TREBLE.value / BASS.value (via @live on VolumeKnobState).

Driving from outside

The custom knob takes the same live commands every slider does:

# snapshot — knobs appear under "kind":"knob" with bbox + hex_id + payload
curl -X POST -d '{"name":"imgui_snapshot"}' localhost:9090/command

# set a value programmatically — pending_value_finalize handles the dispatch
curl -X POST -d '{"name":"imgui_force_set","args":{"target":"MIXER_WIN/MASTER","value":0.75}}' \
     localhost:9090/command

# click the built-in reset button next to the knobs
curl -X POST -d '{"name":"imgui_click","args":{"target":"MIXER_WIN/RESET_BTN"}}' \
     localhost:9090/command

The snapshot payload carries value, bounds, changed — whatever fields the state struct declares. No per-kind glue: the state_jv helper introspects the struct at compile time and serializes every field.

Next steps

This is the same pattern every built-in widget uses. To wire a wholly new kind that needs its own dispatcher action (e.g. a 2-D pad with set_xy), copy a *_finalize helper from widgets/imgui_widgets_builtin.das and rename one action key.

See also

Full source: examples/tutorial/custom_widgets.das

Driver script: modules/dasImgui/tests/integration/record_custom_widgets.das — same two-shell pattern as Recording tutorial videos.

Previous tutorial: Widgets tour

Next tutorial: Layout

Boost macros — the [widget] machinery.

Builtin widgets — full widget catalog.