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