Files
lightcraft/crates/ui-egui/src/widgets.rs
T

368 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Lightroom-style widgets: sliders with gradient tracks and hollow thumbs, section headers,
//! flyout rows, bordered buttons. Every interactive widget registers an automation id + rect
//! ([`register`]) so the control channel can find and click it by name.
use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use lightcraft_develop::{ControlSpec, Track};
use crate::icons::{Icon, paint};
use crate::theme::Tokens;
// ------------------------------------------------------------------ automation registry
#[derive(Clone, Default)]
pub struct Registry(pub Vec<(String, Rect)>);
/// Record a widget's automation id and screen rect for this frame.
pub fn register(ctx: &egui::Context, id: impl Into<String>, rect: Rect) {
let id = id.into();
ctx.data_mut(|d| d.get_temp_mut_or_default::<Registry>(egui::Id::new("lc-registry")).0.push((id, rect)));
}
pub fn take_registry(ctx: &egui::Context) -> Vec<(String, Rect)> {
ctx.data_mut(|d| std::mem::take(&mut d.get_temp_mut_or_default::<Registry>(egui::Id::new("lc-registry")).0))
}
// ------------------------------------------------------------------ colour helpers
pub fn hex(s: &str) -> Color32 {
let v = u32::from_str_radix(s.trim_start_matches('#'), 16).unwrap_or(0x808080);
Color32::from_rgb((v >> 16) as u8, (v >> 8) as u8, v as u8)
}
pub fn lerp(a: Color32, b: Color32, t: f32) -> Color32 {
let t = t.clamp(0.0, 1.0);
let f = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8;
Color32::from_rgb(f(a.r(), b.r()), f(a.g(), b.g()), f(a.b(), b.b()))
}
/// Band colours for the 8 colour-mixer bands (swatches and tracks).
pub const BAND_COLORS: [&str; 8] = ["#aa0000", "#bb6600", "#aaaa00", "#009900", "#00aaaa", "#0044cc", "#7722aa", "#aa0077"];
fn track_stops(track: &Track) -> Option<Vec<Color32>> {
Some(match track {
Track::Plain | Track::Centered => return None,
Track::Temp => ["#193961", "#47566a", "#60666d", "#70705b", "#737339", "#75742a"].iter().map(|h| hex(h)).collect(),
Track::Tint => ["#246123", "#2c552c", "#394739", "#4a3d4c", "#6b336b", "#872c87"].iter().map(|h| hex(h)).collect(),
Track::Gradient { from, to } => vec![hex(from), hex(to)],
Track::Hue { band } => {
let i = *band as usize;
let prev = hex(BAND_COLORS[(i + 7) % 8]);
let next = hex(BAND_COLORS[(i + 1) % 8]);
let me = hex(BAND_COLORS[i]);
vec![
prev.gamma_multiply(0.7),
lerp(prev, me, 0.5).gamma_multiply(0.7),
me.gamma_multiply(0.7),
lerp(me, next, 0.5).gamma_multiply(0.7),
next.gamma_multiply(0.7),
]
}
Track::Sat { band } => vec![hex("#454343"), lerp(hex("#454343"), hex(BAND_COLORS[*band as usize]), 0.6), hex(BAND_COLORS[*band as usize])],
Track::Lum { band } => {
let c = hex(BAND_COLORS[*band as usize]);
vec![c.gamma_multiply(0.2), c.gamma_multiply(0.55), lerp(c, Color32::WHITE, 0.35), lerp(c, Color32::WHITE, 0.6)]
}
Track::Rainbow => ["#aa0000", "#aaaa00", "#009900", "#00aaaa", "#0044cc", "#aa0077", "#aa0000"].iter().map(|h| hex(h)).collect(),
})
}
fn paint_track(ui: &Ui, rect: Rect, track: &Track, t: &Tokens, thumb_x: f32, ring: f32) {
let p = ui.painter();
let y = rect.center().y;
let h = 1.0;
if let Some(stops) = track_stops(track) {
// gradient: segments between stops
let n = stops.len().max(2) - 1;
let steps = 48;
for i in 0..steps {
let (a, b) = (i as f32 / steps as f32, (i + 1) as f32 / steps as f32);
let seg = (a * n as f32).floor() as usize;
let local = a * n as f32 - seg as f32;
let c = lerp(stops[seg.min(n)], stops[(seg + 1).min(n)], local);
let x0 = rect.left() + rect.width() * a;
let x1 = rect.left() + rect.width() * b;
p.rect_filled(Rect::from_min_max(pos2(x0, y - h - 0.5), pos2(x1 + 0.5, y + h + 0.5)), 0.0, c);
}
} else {
p.rect_filled(Rect::from_min_max(pos2(rect.left(), y - h), pos2(rect.right(), y + h)), 1.0, t.track);
}
// gap around the hollow thumb
p.rect_filled(Rect::from_center_size(pos2(thumb_x, y), vec2(ring * 2.0 + 4.0, 6.0)), 0.0, t.chrome);
}
/// Result of a slider interaction this frame.
#[derive(Default, Debug, Clone, Copy)]
pub struct SliderOut {
pub value: Option<f64>,
pub drag_started: bool,
pub drag_stopped: bool,
pub reset: bool,
}
/// A Lightroom slider row (label + value above a track with a hollow ring thumb).
/// Double-click the label or thumb to reset. Shift-drag = fine adjustment.
pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (row, _) = ui.allocate_exact_size(vec2(w, t.slider_row_h), Sense::hover());
let pad_l = 24.0;
let pad_r = 22.0;
let label_rect = Rect::from_min_size(pos2(row.left() + pad_l, row.top() + 4.0), vec2(w - pad_l - pad_r, 18.0));
let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0));
let id = ui.id().with(spec.id);
let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() });
let label_resp = ui.interact(label_rect, id.with("label"), Sense::click());
register(ui.ctx(), format!("slider:{}", spec.id), track_rect);
let mut out = SliderOut::default();
let span = (spec.max - spec.min).max(1e-9);
let to_x = |v: f64| track_rect.left() + ((v - spec.min) / span).clamp(0.0, 1.0) as f32 * track_rect.width();
let from_x = |x: f32| spec.min + ((x - track_rect.left()) / track_rect.width()).clamp(0.0, 1.0) as f64 * span;
let mut v = value;
if resp.double_clicked() || label_resp.double_clicked() {
out.reset = true;
out.value = Some(spec.default);
v = spec.default;
} else {
if resp.drag_started() {
out.drag_started = true;
}
if (resp.dragged() || resp.drag_started())
&& let Some(p) = resp.interact_pointer_pos()
{
let fine = ui.input(|i| i.modifiers.shift);
let nv = if fine {
let dx = ui.input(|i| i.pointer.delta().x);
value + dx as f64 * span / track_rect.width() as f64 * 0.1
} else {
from_x(p.x)
};
let step = spec.step.max(1e-9);
let nv = ((nv / step).round() * step).clamp(spec.min, spec.max);
if (nv - value).abs() > 1e-12 {
out.value = Some(nv);
v = nv;
}
} else if resp.clicked()
&& let Some(p) = resp.interact_pointer_pos()
{
let step = spec.step.max(1e-9);
let nv = ((from_x(p.x) / step).round() * step).clamp(spec.min, spec.max);
out.value = Some(nv);
v = nv;
out.drag_started = true;
out.drag_stopped = true;
}
if resp.drag_stopped() {
out.drag_stopped = true;
}
}
// paint
let hovered = resp.hovered() || resp.dragged();
let text_c = if enabled { t.text_label } else { t.text_disabled };
let p = ui.painter();
p.text(label_rect.left_center(), Align2::LEFT_CENTER, label_override.unwrap_or(spec.label), t.font(12.5), text_c);
let shown = if spec.id == "wb.temp" { format!("{v:.0}") } else { spec.format(v).replace("+0.00", "0").replace("-0.00", "0") };
let shown = if shown == "+0" || shown == "-0" { "0".to_string() } else { shown };
p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown, t.font(12.5), text_c);
let ring = 7.0;
let tx = to_x(v);
paint_track(ui, track_rect, &spec.track, &t, tx, ring);
let p = ui.painter();
let ring_c = if !enabled {
t.text_disabled
} else if hovered {
t.thumb_hover
} else {
t.thumb
};
p.circle_filled(pos2(tx, track_rect.center().y), ring, t.chrome);
p.circle_stroke(pos2(tx, track_rect.center().y), ring, Stroke::new(2.0, ring_c));
if hovered {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
out
}
/// Collapsible section header ("› Light"). Returns the response (click toggles).
pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: Option<bool>) -> (Response, Option<bool>) {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (r, resp) = ui.allocate_exact_size(vec2(w, 52.0), Sense::click());
register(ui.ctx(), format!("section:{id}"), r);
let p = ui.painter();
if resp.hovered() {
p.rect_filled(r, 0.0, t.chrome.gamma_multiply(1.06));
}
let chev = Rect::from_center_size(pos2(r.left() + 30.0, r.center().y), vec2(14.0, 14.0));
paint(p, chev, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label);
p.text(pos2(r.left() + 44.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(14.0), t.text);
let mut toggled = None;
if let Some(on) = enabled
&& (resp.hovered() || !on)
{
let eye = Rect::from_center_size(pos2(r.right() - 34.0, r.center().y), vec2(18.0, 18.0));
let er = ui.interact(eye, ui.id().with(("eye", id)), Sense::click());
register(ui.ctx(), format!("sectionEye:{id}"), eye);
paint(ui.painter(), eye, Icon::Eye, if on { t.icon } else { t.text_disabled });
if !on {
ui.painter().line_segment([eye.left_bottom(), eye.right_top()], Stroke::new(1.5, t.text_disabled));
}
if er.clicked() {
toggled = Some(!on);
}
}
(resp, toggled)
}
/// Thin divider between groups.
pub fn divider(ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (r, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover());
ui.painter().rect_filled(r, 0.0, t.divider);
}
/// A flyout row inside a section (Curve, Color Mixer, Color Grading…): inset dark box with icon tile.
pub fn flyout_row(ui: &mut Ui, id: &str, title: &str, icon: Icon, open: bool) -> Response {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (outer, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover());
let r = Rect::from_min_max(pos2(outer.left() + 16.0, outer.top() + 4.0), pos2(outer.right() - 16.0, outer.bottom() - 4.0));
let resp = ui.interact(r, ui.id().with(("flyout", id)), Sense::click());
register(ui.ctx(), format!("flyout:{id}"), r);
let p = ui.painter();
p.rect_filled(r, 4.0, if resp.hovered() { t.hover.gamma_multiply(0.8) } else { t.inset });
let tile = Rect::from_min_size(pos2(r.left() + 4.0, r.top() + 4.0), vec2(r.height() - 8.0, r.height() - 8.0));
p.rect_filled(tile, 3.0, t.chrome.gamma_multiply(1.15));
paint(p, tile.shrink(3.0), icon, t.text_label);
p.text(pos2(tile.right() + 10.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label);
paint(
p,
Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(12.0, 12.0)),
if open { Icon::ChevronDown } else { Icon::TriangleLeft },
t.text_label,
);
resp
}
/// A small bordered text button (Auto, B&W, HDR…).
pub fn text_button(ui: &mut Ui, id: &str, label: &str, active: bool) -> Response {
let t = Tokens::get(ui.ctx());
let font = t.semibold(11.5);
let galley = ui.painter().layout_no_wrap(label.to_string(), font, t.text);
let size = vec2((galley.size().x + 18.0).max(37.0), 24.0);
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
register(ui.ctx(), format!("button:{id}"), r);
let fill = if active {
t.pressed
} else if resp.hovered() {
t.hover
} else {
t.button
};
let p = ui.painter();
p.rect(r, CornerRadius::same(4), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside);
p.galley(r.center() - galley.size() / 2.0, galley, t.text);
resp
}
/// A segmented control: `labels` share the available width equally, `per_row` per row. Each segment is
/// addressable as `button:{id}-{key}` (keys parallel to labels). Returns the clicked index.
pub fn segmented(ui: &mut Ui, id: &str, items: &[(&str, &str)], active: Option<usize>, per_row: usize) -> Option<usize> {
let t = Tokens::get(ui.ctx());
let per_row = per_row.max(1);
let gap = 4.0;
let w = ui.available_width();
let seg_w = ((w - gap * (per_row as f32 - 1.0)) / per_row as f32).max(24.0);
let mut clicked = None;
for (row_i, row) in items.chunks(per_row).enumerate() {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = gap;
for (j, (label, key)) in row.iter().enumerate() {
let i = row_i * per_row + j;
let (r, resp) = ui.allocate_exact_size(vec2(seg_w, 24.0), Sense::click());
register(ui.ctx(), format!("button:{id}-{key}"), r);
let fill = if active == Some(i) {
t.pressed
} else if resp.hovered() {
t.hover
} else {
t.button
};
let p = ui.painter();
p.rect(r, CornerRadius::same(4), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside);
p.text(r.center(), Align2::CENTER_CENTER, *label, t.semibold(11.5), t.text);
if resp.clicked() {
clicked = Some(i);
}
}
});
}
clicked
}
/// An icon-only button. `active` draws the selected background (tool strip).
pub fn icon_button(ui: &mut Ui, id: &str, icon: Icon, size: egui::Vec2, active: bool, enabled: bool, tooltip: &str) -> Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(size, if enabled { Sense::click() } else { Sense::hover() });
register(ui.ctx(), format!("icon:{id}"), r);
let p = ui.painter();
let sq = Rect::from_center_size(r.center(), vec2(32.0f32.min(size.x), 32.0f32.min(size.y)));
if active {
p.rect_filled(sq, 4.0, t.tool_active);
} else if resp.hovered() && enabled {
p.rect_filled(sq, 4.0, t.hover.gamma_multiply(0.7));
}
let c = if !enabled {
t.text_disabled
} else if active {
t.pick
} else if resp.hovered() {
t.text
} else {
t.icon
};
let glyph = (size.x.min(size.y) * 0.62).min(22.0);
paint(p, Rect::from_center_size(r.center(), vec2(glyph, glyph)), icon, c);
if !tooltip.is_empty() { resp.on_hover_text(tooltip) } else { resp }
}
/// Star rating row (5 stars); returns a new rating when clicked (clicking the current rating clears).
pub fn stars(ui: &mut Ui, id: &str, rating: u8, size: f32) -> Option<u8> {
let t = Tokens::get(ui.ctx());
let mut out = None;
let hover_n = {
let mut h = None;
for i in 0..5u8 {
let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click());
register(ui.ctx(), format!("star:{id}:{}", i + 1), r);
if resp.hovered() {
h = Some(i + 1);
}
if resp.clicked() {
out = Some(if rating == i + 1 { 0 } else { i + 1 });
}
ui.painter().text(r.center(), Align2::CENTER_CENTER, "", t.font(1.0), t.star);
let filled = i < rating;
paint(ui.painter(), r.shrink(size * 0.12), if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon });
}
h
};
let _ = hover_n;
out
}
/// A borderless dropdown button: `text` followed by a painted chevron (no font glyph needed).
pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Color32) -> Response {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(text.to_string(), font, color);
let size = vec2(galley.size().x + 20.0, galley.size().y.max(20.0));
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
register(ui.ctx(), format!("dropdown:{id}"), r);
let c = if resp.hovered() { t.text } else { color };
ui.painter().galley(pos2(r.left(), r.center().y - galley.size().y / 2.0), galley, c);
paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 7.0, r.center().y + 1.0), vec2(12.0, 12.0)), Icon::ChevronDown, c);
resp
}