// Copyright 2026 Ling71671
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// Component style descriptor for rotary knob controls.
pub struct KnobStyle {
radius : Double
} derive(Eq, Debug)
///|
pub fn KnobStyle::default() -> KnobStyle {
{ radius: 20.0, }
}
///|
fn draw_arc(
p : Painter,
center : @math.Vec2,
radius : Double,
start_angle : Double,
end_angle : Double,
color : @color.Color,
width : Double,
segments : Int,
) -> Unit {
let sweep = end_angle - start_angle
if sweep.abs() < 0.001 || segments <= 0 {
return
}
let step = sweep / segments.to_double()
let mut prev_p = center.add(@math.Vec2::from_polar(radius, start_angle))
for i = 1; i <= segments; i = i + 1 {
let a = start_angle + step * i.to_double()
let cur_p = center.add(@math.Vec2::from_polar(radius, a))
p.add_line(prev_p, cur_p, color, width)
prev_p = cur_p
}
}
///|
/// Standalone rotary knob controller widget supporting fluent configuration.
pub struct Knob {
id_salt : String
label : String
value : Double
min_val : Double
max_val : Double
step : Double
default_val : Double
unit : String
radius : Double
bipolar : Bool
} derive(Eq, Debug)
///|
/// Create a new rotary knob widget with standard defaults.
pub fn Knob::new(id_salt : String, label : String, value : Double) -> Knob {
{
id_salt,
label,
value,
min_val: 0.0,
max_val: 1.0,
step: 0.01,
default_val: 0.0,
unit: "",
radius: 22.0,
bipolar: false,
}
}
///|
/// Set minimum parameter value.
pub fn Knob::min(self : Knob, min_val : Double) -> Knob {
{ ..self, min_val, }
}
///|
/// Set maximum parameter value.
pub fn Knob::max(self : Knob, max_val : Double) -> Knob {
{ ..self, max_val, }
}
///|
/// Set adjustment step increment.
pub fn Knob::step(self : Knob, step : Double) -> Knob {
{ ..self, step, }
}
///|
/// Set default reset value (triggered on Alt+Click).
pub fn Knob::default_val(self : Knob, default_val : Double) -> Knob {
{ ..self, default_val, }
}
///|
/// Set display unit suffix (e.g. "dB", "Hz", "%").
pub fn Knob::unit(self : Knob, unit : String) -> Knob {
{ ..self, unit, }
}
///|
/// Set knob dial radius in pixels.
pub fn Knob::radius(self : Knob, radius : Double) -> Knob {
{ ..self, radius, }
}
///|
/// Enable bipolar center-zero mode (-max to +max, center arc).
pub fn Knob::bipolar(self : Knob, bipolar : Bool) -> Knob {
{ ..self, bipolar, }
}
///|
/// Reference-bound knob widget for uniform `ui.add(...)` dispatch.
pub struct KnobRef {
knob : Knob
target : Ref[Double]
}
///|
/// Create a rotary knob bound directly to a `Ref[Double]`.
pub fn Knob::from_ref(
id_salt : String,
label : String,
target : Ref[Double],
) -> KnobRef {
{ knob: Knob::new(id_salt, label, target.val), target, }
}
///|
pub fn KnobRef::min(self : KnobRef, min_val : Double) -> KnobRef {
{ ..self, knob: self.knob.min(min_val), }
}
///|
pub fn KnobRef::max(self : KnobRef, max_val : Double) -> KnobRef {
{ ..self, knob: self.knob.max(max_val), }
}
///|
pub fn KnobRef::step(self : KnobRef, step : Double) -> KnobRef {
{ ..self, knob: self.knob.step(step), }
}
///|
pub fn KnobRef::default_val(self : KnobRef, default_val : Double) -> KnobRef {
{ ..self, knob: self.knob.default_val(default_val), }
}
///|
pub fn KnobRef::unit(self : KnobRef, unit : String) -> KnobRef {
{ ..self, knob: self.knob.unit(unit), }
}
///|
pub fn KnobRef::radius(self : KnobRef, radius : Double) -> KnobRef {
{ ..self, knob: self.knob.radius(radius), }
}
///|
pub fn KnobRef::bipolar(self : KnobRef, bipolar : Bool) -> KnobRef {
{ ..self, knob: self.knob.bipolar(bipolar), }
}
///|
pub impl @core.Widget for KnobRef with fn ui(self, ctx : UIContext) -> Response {
let (new_val, resp) = { ..self.knob, value: self.target.val, }.show(ctx)
self.target.val = new_val
resp
}
///|
/// Render the knob and return the updated value alongside the interaction response.
pub fn Knob::show(self : Knob, ctx : UIContext) -> (Double, Response) {
let min_val = self.min_val
let max_val = self.max_val
let step = self.step
let default_val = self.default_val
let unit = self.unit
let scale = ctx.style.scale
let radius = self.radius * scale
let bipolar = self.bipolar
let label = self.label
let value = self.value
let range = if max_val > min_val { max_val - min_val } else { 1.0 }
let raw_val = if value.is_nan() { default_val } else { value }
let clamped_initial = if raw_val < min_val {
min_val
} else if raw_val > max_val {
max_val
} else {
raw_val
}
let diameter = radius * 2.0
let label_h = if label.is_empty() { 0.0 } else { 15.0 * scale }
let val_h = 16.0 * scale
let total_w = diameter + 16.0 * scale
let total_h = diameter + label_h + val_h + 8.0 * scale
let id = ctx.id_stack.derive_id("knob::" + self.id_salt)
let (_, rect, response) = ctx.allocate_space(
@math.Vec2::new(total_w, total_h),
)
ctx.register_focusable(id)
if response.pressed() || response.clicked() {
ctx.request_focus(id)
}
if response.hovered() || response.dragged() || response.pressed() {
ctx.set_cursor_icon("ns-resize")
}
let is_focused = ctx.has_focus(id)
let mut current_val = clamped_initial
// 1. Mouse Drag Gesture (Vertical delta is primary industry standard)
if !ctx.input.modifiers.alt &&
(response.dragged() || (response.pressed() && ctx.input.mouse_down)) {
let dy = -ctx.input.mouse_delta.y
let dx = ctx.input.mouse_delta.x
let delta = dy + dx
let sensitivity = if ctx.input.modifiers.shift { 0.001 } else { 0.005 }
current_val = current_val + delta * range * sensitivity
}
// 2. Mouse Scroll Wheel
if response.hovered() && ctx.input.scroll_delta.y.abs() > 0.0 {
let dir = if ctx.input.scroll_delta.y > 0.0 { 1.0 } else { -1.0 }
current_val = current_val + dir * step
}
// 3. Keyboard Navigation when focused
if is_focused {
if ctx.input.key_pressed(Key::ArrowUp) ||
ctx.input.key_pressed(Key::ArrowRight) {
current_val = current_val + step
ctx.input.consume_key(Key::ArrowUp)
ctx.input.consume_key(Key::ArrowRight)
}
if ctx.input.key_pressed(Key::ArrowDown) ||
ctx.input.key_pressed(Key::ArrowLeft) {
current_val = current_val - step
ctx.input.consume_key(Key::ArrowDown)
ctx.input.consume_key(Key::ArrowLeft)
}
if ctx.input.key_pressed(Key::Home) {
current_val = min_val
ctx.input.consume_key(Key::Home)
}
if ctx.input.key_pressed(Key::End) {
current_val = max_val
ctx.input.consume_key(Key::End)
}
let page_step = step * 10.0
if ctx.input.key_pressed(Key::PageUp) {
current_val = current_val + page_step
ctx.input.consume_key(Key::PageUp)
}
if ctx.input.key_pressed(Key::PageDown) {
current_val = current_val - page_step
ctx.input.consume_key(Key::PageDown)
}
if ctx.input.key_pressed(Key::Backspace) ||
ctx.input.key_pressed(Key::Delete) {
current_val = default_val
ctx.input.consume_key(Key::Backspace)
ctx.input.consume_key(Key::Delete)
}
}
// 4. Reset to default on Alt+Click or Alt+Press
if (response.pressed() || response.clicked()) && ctx.input.modifiers.alt {
current_val = default_val
}
// 4. Quantize and clamp
if current_val.is_nan() {
current_val = default_val
}
if step > 0.0 {
let steps_count = ((current_val - min_val) / step + 0.5)
.to_int()
.to_double()
current_val = min_val + steps_count * step
}
let final_val = if current_val < min_val {
min_val
} else if current_val > max_val {
max_val
} else {
current_val
}
// 5. Visual Rendering
let center_x = rect.x + total_w * 0.5
let center_y = rect.y + label_h + 4.0 * scale + radius
let center = @math.Vec2::new(center_x, center_y)
let dial_radius = radius - 3.5 * scale
// 270 degree sweep: from 135 deg (0.75 pi) to 405 deg (2.25 pi)
let start_angle = 0.75 * @math.pi
let total_sweep = 1.5 * @math.pi
let end_angle = start_angle + total_sweep
let p = ctx.painter()
// A. Background track arc
draw_arc(
p,
center,
radius,
start_angle,
end_angle,
@color.Color::border_muted(),
3.5 * scale,
20,
)
// B. Active Indicator Arc
let raw_norm = (final_val - min_val) / range
let norm = if raw_norm.is_nan() || raw_norm < 0.0 {
0.0
} else if raw_norm > 1.0 {
1.0
} else {
raw_norm
}
if bipolar {
let mid_angle = 1.5 * @math.pi // Top center (270 deg)
let curr_angle = start_angle + norm * total_sweep
let segs = ((curr_angle - mid_angle).abs() / total_sweep * 16.0).to_int() +
2
draw_arc(
p,
center,
radius,
mid_angle,
curr_angle,
@color.Color::accent_primary(),
3.5 * scale,
segs,
)
} else {
let curr_angle = start_angle + norm * total_sweep
let segs = (norm * 20.0).to_int() + 2
draw_arc(
p,
center,
radius,
start_angle,
curr_angle,
@color.Color::accent_primary(),
3.5 * scale,
segs,
)
}
// C. Dial Cap (White Porcelain disc + border + shadow)
p.add_circle(
center.add(@math.Vec2::new(0.0, 1.5 * scale)),
dial_radius,
@color.Color::shadow(),
)
let cap_col = if response.pressed() {
@color.Color::bg_active()
} else if response.hovered() {
@color.Color::bg_hover()
} else {
@color.Color::bg_window()
}
p.add_circle(center, dial_radius, cap_col)
let border_col = if is_focused {
@color.Color::border_focus()
} else {
@color.Color::border_default()
}
p.add_circle_stroke(center, dial_radius, border_col, 1.0 * scale)
// D. Laser-engraved pointer tick
let pointer_angle = start_angle + norm * total_sweep
let tick_start = center.add(
@math.Vec2::from_polar(dial_radius * 0.35, pointer_angle),
)
let tick_end = center.add(
@math.Vec2::from_polar(dial_radius * 0.85, pointer_angle),
)
let tick_col = if response.pressed() {
@color.Color::accent_primary()
} else {
@color.Color::text_secondary()
}
p.add_line(tick_start, tick_end, tick_col, 2.0 * scale)
let font_sm = ctx.style.font_small * scale
// E. Uppercase Label (Top)
if !label.is_empty() {
let label_w = ctx.measure_text(label, font_sm).x
p.add_text(
@math.Vec2::new(center_x - label_w * 0.5, rect.y),
label,
font_sm,
@color.Color::text_muted(),
)
}
// F. Numeric Readout Badge (Bottom)
let val_str = if unit.is_empty() {
final_val.to_string()
} else {
final_val.to_string() + " " + unit
}
let val_w = ctx.measure_text(val_str, font_sm).x
let val_col = if is_focused || response.pressed() {
@color.Color::accent_primary()
} else {
@color.Color::text_strong()
}
p.add_text(
@math.Vec2::new(center_x - val_w * 0.5, center_y + radius + 4.0 * scale),
val_str,
font_sm,
val_col,
)
(final_val, response)
}
///|
/// Immediate-mode rotary knob parameter controller in Studio Light porcelain style.
/// Features 270-degree arc track, unipolar/bipolar sweeps, vertical drag delta,
/// scroll wheel stepping, Shift precision scaling, and monospace readouts.
pub fn knob(
ctx : UIContext,
id_salt : String,
label : String,
value : Double,
min_val? : Double = 0.0,
max_val? : Double = 1.0,
step? : Double = 0.01,
default_val? : Double = 0.0,
unit? : String = "",
radius? : Double = 22.0,
bipolar? : Bool = false,
) -> (Double, Response) {
Knob::new(id_salt, label, value)
.min(min_val)
.max(max_val)
.step(step)
.default_val(default_val)
.unit(unit)
.radius(radius)
.bipolar(bipolar)
.show(ctx)
}
///|
pub fn knob_int(
ctx : UIContext,
id_salt : String,
label : String,
value : Int,
min_val? : Int = 0,
max_val? : Int = 100,
step? : Int = 1,
default_val? : Int = 0,
unit? : String = "",
radius? : Double = 22.0,
bipolar? : Bool = false,
) -> (Int, Response) {
let (v, resp) = knob(
ctx,
id_salt,
label,
value.to_double(),
min_val=min_val.to_double(),
max_val=max_val.to_double(),
step=step.to_double(),
default_val=default_val.to_double(),
unit~,
radius~,
bipolar~,
)
(v.round().to_int(), resp)
}