// 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.
///|
/// Convert HSV color components (h in [0, 360], s in [0, 1], v in [0, 1]) to standard RGB.
pub fn hsv_to_rgb(h : Double, s : Double, v : Double) -> (Int, Int, Int) {
let h_clamped = (h % 360.0 + 360.0) % 360.0
let s_clamped = if s < 0.0 { 0.0 } else if s > 1.0 { 1.0 } else { s }
let v_clamped = if v < 0.0 { 0.0 } else if v > 1.0 { 1.0 } else { v }
let c = v_clamped * s_clamped
let h_sector = h_clamped / 60.0
let x = c * (1.0 - (h_sector % 2.0 - 1.0).abs())
let m = v_clamped - c
let (r1, g1, b1) = if h_sector < 1.0 {
(c, x, 0.0)
} else if h_sector < 2.0 {
(x, c, 0.0)
} else if h_sector < 3.0 {
(0.0, c, x)
} else if h_sector < 4.0 {
(0.0, x, c)
} else if h_sector < 5.0 {
(x, 0.0, c)
} else {
(c, 0.0, x)
}
let r = ((r1 + m) * 255.0 + 0.5).to_int()
let g = ((g1 + m) * 255.0 + 0.5).to_int()
let b = ((b1 + m) * 255.0 + 0.5).to_int()
(r, g, b)
}
///|
/// Convert RGB integer components (0..255) to HSV color representation.
pub fn rgb_to_hsv(r : Int, g : Int, b : Int) -> (Double, Double, Double) {
let rf = r.to_double() / 255.0
let gf = g.to_double() / 255.0
let bf = b.to_double() / 255.0
let cmax = if rf > gf {
if rf > bf {
rf
} else {
bf
}
} else if gf > bf {
gf
} else {
bf
}
let cmin = if rf < gf {
if rf < bf {
rf
} else {
bf
}
} else if gf < bf {
gf
} else {
bf
}
let delta = cmax - cmin
let v = cmax
let s = if cmax > 0.00001 { delta / cmax } else { 0.0 }
let h = if delta < 0.00001 {
0.0
} else if cmax == rf {
let mut val = 60.0 * ((gf - bf) / delta)
if val < 0.0 {
val = val + 360.0
}
val
} else if cmax == gf {
60.0 * ((bf - rf) / delta) + 120.0
} else {
60.0 * ((rf - gf) / delta) + 240.0
}
(h, s, v)
}
///|
fn hex_byte(val : Int) -> String {
let hex_digits = "0123456789ABCDEF"
let hi = (val >> 4) & 0xF
let lo = val & 0xF
let hi_s = hex_digits[hi:hi + 1].to_owned()
let lo_s = hex_digits[lo:lo + 1].to_owned()
hi_s + lo_s
}
///|
/// Render a full interactive 2D HSV color picker panel featuring a 2D sat/val square,
/// rainbow hue slider, alpha opacity slider, and dual-ring high-contrast grip.
pub fn color_picker(
ctx : UIContext,
id_salt : String,
color : @color.Color,
show_alpha? : Bool = true,
) -> (@color.Color, Response) {
let scale = ctx.style.scale
let width = 180.0 * scale
let height = (if show_alpha { 230.0 } else { 206.0 }) * scale
ctx.push_id(id_salt)
let (_panel_id, rect, resp) = ctx.allocate_space(
@math.Vec2::new(width, height),
)
ctx.pop_id()
// Panel background card
let card_radius = ctx.style.radius_md * scale
let inner_radius = ctx.style.radius_sm * scale
ctx.painter().add_rect(rect, @color.Color::bg_surface(), card_radius)
ctx
.painter()
.add_rect_stroke(
rect,
@color.Color::border_default(),
1.0 * scale,
card_radius,
)
let pad = 10.0 * scale
let sq_x = rect.x + pad
let sq_y = rect.y + pad
let sq_w = rect.w - pad * 2.0
let sq_h = 126.0 * scale
let sq_rect = @math.Rect::new(sq_x, sq_y, sq_w, sq_h)
let (init_h, init_s, init_v) = rgb_to_hsv(color.r, color.g, color.b)
let panel_id = ctx.id_stack.derive_id("color_picker::" + id_salt)
let mut h = if init_s > 0.001 && init_v > 0.001 {
ctx
.memory()
.set_double(panel_id, init_h, tag="hue", frame=ctx.frame_counter())
init_h
} else {
ctx.memory().get_double_or_default(panel_id, init_h, tag="hue")
}
let mut s = init_s
let mut v = init_v
let mut a = color.a
// 1. 2D Saturation/Value Area Interaction
let sv_id = ctx.id_stack.derive_id("cp_sv::" + id_salt)
let sv_hovered = ctx.is_hovered(sq_rect)
if sv_hovered || ctx.active_id() == sv_id {
ctx.set_cursor_icon("crosshair")
}
if sv_hovered && ctx.input.mouse_pressed {
ctx.set_active_id(sv_id)
}
if ctx.active_id() == sv_id {
ctx.set_wants_capture_mouse(true)
if ctx.input.mouse_down {
let raw_s = (ctx.input.mouse_pos.x - sq_x) / sq_w
let raw_v = 1.0 - (ctx.input.mouse_pos.y - sq_y) / sq_h
s = if raw_s < 0.0 { 0.0 } else if raw_s > 1.0 { 1.0 } else { raw_s }
v = if raw_v < 0.0 { 0.0 } else if raw_v > 1.0 { 1.0 } else { raw_v }
}
if ctx.input.mouse_released {
ctx.set_active_id(Id::zero())
}
}
// Draw 2D Sat/Val Surface using smooth continuous gradients
let (pure_r, pure_g, pure_b) = hsv_to_rgb(h, 1.0, 1.0)
ctx
.painter()
.add_rect(sq_rect, @color.Color::rgb(pure_r, pure_g, pure_b), inner_radius)
ctx
.painter()
.add_linear_gradient(
sq_rect,
@math.Vec2::new(sq_x, sq_y),
@math.Vec2::new(sq_x + sq_w, sq_y),
@color.Color::rgba(255, 255, 255, 255),
@color.Color::rgba(255, 255, 255, 0),
inner_radius,
)
ctx
.painter()
.add_linear_gradient(
sq_rect,
@math.Vec2::new(sq_x, sq_y),
@math.Vec2::new(sq_x, sq_y + sq_h),
@color.Color::rgba(0, 0, 0, 0),
@color.Color::rgba(0, 0, 0, 255),
inner_radius,
)
// Border outline for SV square
ctx
.painter()
.add_rect_stroke(
sq_rect,
@color.Color::border_muted(),
1.0 * scale,
inner_radius,
)
// Dual-ring cursor on SV area
let cur_x = sq_x + s * sq_w
let cur_y = sq_y + (1.0 - v) * sq_h
let cur_center = @math.Vec2::new(cur_x, cur_y)
ctx
.painter()
.add_circle_stroke(
cur_center,
5.0 * scale,
@color.Color::bg_window(),
1.6 * scale,
)
ctx
.painter()
.add_circle_stroke(
cur_center,
3.5 * scale,
@color.Color::text_primary(),
1.2 * scale,
)
// 2. Hue Rainbow Slider
let hue_y = sq_y + sq_h + 8.0 * scale
let hue_h = 12.0 * scale
let hue_rect = @math.Rect::new(sq_x, hue_y, sq_w, hue_h)
let hue_id = ctx.id_stack.derive_id("cp_hue::" + id_salt)
let hue_hovered = ctx.is_hovered(hue_rect)
if hue_hovered || ctx.active_id() == hue_id {
ctx.set_cursor_icon("ew-resize")
}
if hue_hovered && ctx.input.mouse_pressed {
ctx.set_active_id(hue_id)
}
if ctx.active_id() == hue_id {
ctx.set_wants_capture_mouse(true)
if ctx.input.mouse_down {
let raw_h = (ctx.input.mouse_pos.x - sq_x) / sq_w * 360.0
h = if raw_h < 0.0 { 0.0 } else if raw_h > 360.0 { 360.0 } else { raw_h }
ctx.memory().set_double(panel_id, h, tag="hue", frame=ctx.frame_counter())
}
if ctx.input.mouse_released {
ctx.set_active_id(Id::zero())
}
}
// Draw Continuous Rainbow Multi-Stop Gradient Slices
let rainbow_stops : Array[(Double, Double, @color.Color, @color.Color)] = [
(0.0, 60.0, @color.Color::rgb(255, 0, 0), @color.Color::rgb(255, 255, 0)),
(60.0, 120.0, @color.Color::rgb(255, 255, 0), @color.Color::rgb(0, 255, 0)),
(120.0, 180.0, @color.Color::rgb(0, 255, 0), @color.Color::rgb(0, 255, 255)),
(180.0, 240.0, @color.Color::rgb(0, 255, 255), @color.Color::rgb(0, 0, 255)),
(240.0, 300.0, @color.Color::rgb(0, 0, 255), @color.Color::rgb(255, 0, 255)),
(300.0, 360.0, @color.Color::rgb(255, 0, 255), @color.Color::rgb(255, 0, 0)),
]
let seg_w = sq_w / 6.0
for idx, item in rainbow_stops {
let (_, _, c_start, c_end) = item
let seg_x = sq_x + idx.to_double() * seg_w
let seg_rect = @math.Rect::new(seg_x, hue_y, seg_w + 0.5, hue_h)
let p_start = @math.Vec2::new(seg_x, hue_y)
let p_end = @math.Vec2::new(seg_x + seg_w, hue_y)
let corner_r = if idx == 0 || idx == 5 { inner_radius } else { 0.0 }
ctx
.painter()
.add_linear_gradient(seg_rect, p_start, p_end, c_start, c_end, corner_r)
}
ctx
.painter()
.add_rect_stroke(
hue_rect,
@color.Color::border_muted(),
1.0 * scale,
inner_radius,
)
// Hue Slider Thumb
let hue_thumb_x = sq_x + h / 360.0 * sq_w
let hue_thumb_center = @math.Vec2::new(hue_thumb_x, hue_y + hue_h * 0.5)
ctx
.painter()
.add_circle(hue_thumb_center, 6.0 * scale, @color.Color::bg_window())
ctx
.painter()
.add_circle_stroke(
hue_thumb_center,
6.0 * scale,
@color.Color::border_default(),
1.0 * scale,
)
// 3. Alpha Slider (Optional)
if show_alpha {
let alpha_y = hue_y + hue_h + 8.0 * scale
let alpha_h = 12.0 * scale
let alpha_rect = @math.Rect::new(sq_x, alpha_y, sq_w, alpha_h)
let alpha_id = ctx.id_stack.derive_id("cp_alpha::" + id_salt)
let alpha_hovered = ctx.is_hovered(alpha_rect)
if alpha_hovered || ctx.active_id() == alpha_id {
ctx.set_cursor_icon("ew-resize")
}
if alpha_hovered && ctx.input.mouse_pressed {
ctx.set_active_id(alpha_id)
}
if ctx.active_id() == alpha_id {
ctx.set_wants_capture_mouse(true)
if ctx.input.mouse_down {
let raw_a = (ctx.input.mouse_pos.x - sq_x) / sq_w * 255.0
let val_a = raw_a.to_int()
a = if val_a < 0 { 0 } else if val_a > 255 { 255 } else { val_a }
}
if ctx.input.mouse_released {
ctx.set_active_id(Id::zero())
}
}
// Alpha Bar Background & Continuous Gradient
ctx.painter().add_rect(alpha_rect, @color.Color::bg_subtle(), inner_radius)
let (cur_r, cur_g, cur_b) = hsv_to_rgb(h, s, v)
let c_start = @color.Color::rgba(cur_r, cur_g, cur_b, 0)
let c_end = @color.Color::rgba(cur_r, cur_g, cur_b, 255)
let p_start = @math.Vec2::new(sq_x, alpha_y)
let p_end = @math.Vec2::new(sq_x + sq_w, alpha_y)
ctx
.painter()
.add_linear_gradient(
alpha_rect, p_start, p_end, c_start, c_end, inner_radius,
)
ctx
.painter()
.add_rect_stroke(
alpha_rect,
@color.Color::border_muted(),
1.0 * scale,
inner_radius,
)
// Alpha Slider Thumb
let alpha_thumb_x = sq_x + a.to_double() / 255.0 * sq_w
let alpha_thumb_center = @math.Vec2::new(
alpha_thumb_x,
alpha_y + alpha_h * 0.5,
)
ctx
.painter()
.add_circle(alpha_thumb_center, 6.0 * scale, @color.Color::bg_window())
ctx
.painter()
.add_circle_stroke(
alpha_thumb_center,
6.0 * scale,
@color.Color::border_default(),
1.0 * scale,
)
}
// 4. Color Swatch & Hex Display
let (final_r, final_g, final_b) = hsv_to_rgb(h, s, v)
let updated_color = @color.Color::rgba(final_r, final_g, final_b, a)
let footer_y = if show_alpha {
hue_y + hue_h + 8.0 * scale + 12.0 * scale + 8.0 * scale
} else {
hue_y + hue_h + 8.0 * scale
}
let swatch_rect = @math.Rect::new(sq_x, footer_y, 22.0 * scale, 22.0 * scale)
ctx.painter().add_rect(swatch_rect, updated_color, inner_radius)
ctx
.painter()
.add_rect_stroke(
swatch_rect,
@color.Color::border_default(),
1.0 * scale,
inner_radius,
)
let hex_text = if show_alpha {
"#" +
hex_byte(final_r) +
hex_byte(final_g) +
hex_byte(final_b) +
hex_byte(a)
} else {
"#" + hex_byte(final_r) + hex_byte(final_g) + hex_byte(final_b)
}
let font_sz = ctx.style.font_small * scale
let hex_pos = @math.Vec2::new(
sq_x + 30.0 * scale,
footer_y + (22.0 * scale - font_sz) * 0.5,
)
ctx
.painter()
.add_text(hex_pos, hex_text, font_sz, @color.Color::text_strong())
(updated_color, resp)
}
///|
/// Render a compact color picker button which opens a floating popup color picker when clicked.
pub fn color_picker_button(
ctx : UIContext,
id_salt : String,
color : @color.Color,
show_alpha? : Bool = true,
) -> (@color.Color, Response) {
let scale = ctx.style.scale
let font_sz = ctx.style.font_small * scale
let hex_text = if show_alpha {
"#" +
hex_byte(color.r) +
hex_byte(color.g) +
hex_byte(color.b) +
hex_byte(color.a)
} else {
"#" + hex_byte(color.r) + hex_byte(color.g) + hex_byte(color.b)
}
let txt_sz = ctx.measure_text(hex_text, font_sz)
let btn_w = (20.0 + 8.0 + 16.0) * scale + txt_sz.x
let btn_h = (ctx.style.control_h_sm + ctx.style.spacing_xs) * scale
ctx.push_id(id_salt)
let (id, rect, resp) = ctx.allocate_space(@math.Vec2::new(btn_w, btn_h))
ctx.pop_id()
ctx.register_focusable(id)
let is_focused = resp.has_focus() || ctx.has_focus(id)
let is_hovered = ctx.is_hovered(rect)
let btn_bg = if is_hovered {
@color.Color::bg_hover()
} else {
@color.Color::bg_surface()
}
let btn_radius = ctx.style.radius_sm * scale
ctx.painter().add_rect(rect, btn_bg, btn_radius)
ctx
.painter()
.add_rect_stroke(
rect,
if is_focused {
@color.Color::border_focus()
} else {
@color.Color::border_default()
},
1.0,
btn_radius,
)
if is_focused {
ctx
.painter()
.add_rect_stroke(
rect.expand(2.0 * scale),
@color.Color::border_focus(),
1.5,
btn_radius + 2.0 * scale,
)
}
// Color Swatch Pill
let swatch_size = 16.0 * scale
let swatch_pad = 6.0 * scale
let swatch_rect = @math.Rect::new(
rect.x + swatch_pad,
rect.y + (btn_h - swatch_size) * 0.5,
swatch_size,
swatch_size,
)
ctx.painter().add_rect(swatch_rect, color, btn_radius)
ctx
.painter()
.add_rect_stroke(swatch_rect, @color.Color::border_muted(), 1.0, btn_radius)
// Hex Text
let text_y = rect.y + (btn_h - font_sz) * 0.5
ctx
.painter()
.add_text(
@math.Vec2::new(rect.x + swatch_pad * 2.0 + swatch_size, text_y),
hex_text,
font_sz,
@color.Color::text_body(),
)
let is_open = ctx.open_combo_id() == id
let mut next_color = color
let key_toggle = is_focused &&
(ctx.input.key_pressed(Key::Space) || ctx.input.key_pressed(Key::Enter))
if key_toggle {
ctx.input.consume_key(Key::Space)
ctx.input.consume_key(Key::Enter)
}
if resp.clicked || key_toggle {
if is_open {
ctx.set_open_combo_id(Id::zero())
} else {
ctx.set_open_combo_id(id)
}
}
if is_open {
ctx.begin_foreground()
let popup_pos = @math.Vec2::new(rect.x, rect.y + btn_h + 4.0 * scale)
let prev_cursor = ctx.cursor()
ctx.set_cursor(popup_pos)
let (c, _) = color_picker(ctx, id_salt + "::popup", color, show_alpha~)
next_color = c
ctx.set_cursor(prev_cursor)
ctx.end_foreground()
if ctx.input.key_pressed(Key::Escape) {
ctx.set_open_combo_id(Id::zero())
ctx.input.consume_key(Key::Escape)
}
}
(next_color, resp)
}