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

692 lines
32 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)));
}
/// Esc was handled this frame by something nested (an open popup closed on it): containers such
/// as dialogs shouldn't close too. Keyed to the frame, so it lapses by itself on the next one.
pub fn take_escape(ctx: &egui::Context) {
let frame = ctx.cumulative_frame_nr();
ctx.data_mut(|d| d.insert_temp(egui::Id::new("lc-escape-taken"), frame));
}
/// Whether [`take_escape`] was called this frame.
pub fn escape_taken(ctx: &egui::Context) -> bool {
ctx.data(|d| d.get_temp::<u64>(egui::Id::new("lc-escape-taken"))) == Some(ctx.cumulative_frame_nr())
}
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))
}
// ------------------------------------------------------------------ preview-only raws
/// The decoder's reason why a raw is shown from its embedded preview, shortened for the UI
/// ("Nikon Huffman-compressed NEF (no clean-room …)" → "Nikon Huffman-compressed NEF").
pub fn preview_only_variant(reason: &str) -> &str {
reason.split(" (").next().unwrap_or(reason).trim()
}
/// What a preview-only raw means for the user (see `Photo::preview_only`).
pub fn preview_only_explanation(reason: &str) -> String {
crate::i18n::tr_format!(
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.",
preview_only_variant(reason)
)
}
/// A panel notice for a raw shown from its embedded preview (Edit, Info): an amber info icon,
/// "Preview only" and the explanation. Registered as `notice:previewOnly:{key}`.
pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) {
let t = Tokens::get(ui.ctx());
let r = egui::Frame::NONE
.fill(t.canvas)
.stroke(Stroke::new(1.0, t.caution.gamma_multiply(0.45)))
.corner_radius(6.0)
.inner_margin(egui::Margin { left: 10, right: 10, top: 8, bottom: 9 })
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let (ir, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
paint(ui.painter(), ir, Icon::Info, t.caution);
ui.label(egui::RichText::new(crate::i18n::tr("Preview only")).font(t.semibold(12.5)).color(t.text));
});
ui.add_space(2.0);
ui.label(egui::RichText::new(preview_only_explanation(reason)).size(11.5).color(t.text_label));
})
.response;
let r = r.on_hover_text(crate::i18n::tr_format!("Decoder: {reason}", reason = reason));
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, crate::i18n::tr("Preview only: editing the camera's embedded JPEG")));
register(ui.ctx(), format!("notice:previewOnly:{key}"), r.rect);
}
// ------------------------------------------------------------------ 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,
}
/// What a value typed into a slider asks for (issue #322): a number such as `0.5`, `+12`, `-1,5`
/// (comma or point) or `5600 K`, on the control's steps and clamped to its range. `None` when it
/// isn't a finite number, so the slider keeps its value.
pub fn typed_value(spec: &ControlSpec, text: &str) -> Option<f64> {
let t = text.trim().replace(',', ".");
let t = t.trim_end_matches(|c: char| c == '%' || c == 'K' || c == 'k' || c.is_whitespace());
let v: f64 = t.parse().ok().filter(|v: &f64| v.is_finite())?;
let step = spec.step.max(1e-9);
Some(((v / step).round() * step).clamp(spec.min, spec.max))
}
/// A slider's value as shown next to its label.
pub(crate) fn shown_value(spec: &ControlSpec, v: f64) -> String {
let shown = if spec.id == "wb.temp" { format!("{v:.0}") } else { spec.format(v).replace("+0.00", "0").replace("-0.00", "0") };
if shown == "+0" || shown == "-0" { "0".to_string() } else { shown }
}
/// 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. Click the value to
/// type one: Return or clicking away applies it, Esc keeps the old one.
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() });
// A click selects the slider for arrow/wheel adjustments. Keep scroll-to-adjust
// opt-in so hovering an unfocused slider still scrolls the containing panel.
if enabled && (resp.clicked() || resp.drag_started()) {
resp.request_focus();
}
// screen readers: a slider named after its control, with its value
let label_text = crate::i18n::tr(label_override.unwrap_or(spec.label)).to_string();
resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone()));
let label_resp = ui.interact(label_rect, id.with("label"), Sense::click());
register(ui.ctx(), format!("slider:{}", spec.id), track_rect);
// the value: click it to type an exact one (over the label, so it takes the click)
let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max);
let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click() } else { Sense::hover() });
register(ui.ctx(), format!("sliderValue:{}", spec.id), value_rect);
// say that an exact value can be typed here (issue #534: an exact crop angle was hard to find)
let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value")) } else { value_resp };
let typing_id = id.with("typing");
// the text being typed and how many frames the field has been up: it takes the keyboard on its
// first frames (not on the click's own, whose release would take the focus straight back)
let mut typing: Option<(String, u8)> = ui.data(|m| m.get_temp(typing_id));
if enabled && typing.is_none() && value_resp.clicked() {
typing = Some((shown_value(spec, value).trim_start_matches('+').to_string(), 0));
}
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 let Some((mut text, frames)) = typing.take() {
// a child over the value, so the field never moves the rows around it
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(value_rect).layout(egui::Layout::right_to_left(egui::Align::Center)));
// the old value is selected when the field takes the focus, so typing replaces it
let field = crate::text_field::TextField::singleline(&format!("sliderField:{}", spec.id), &mut text)
.font(t.font(12.5))
.horizontal_align(egui::Align::RIGHT)
.width(value_rect.width())
.margin(egui::Margin::ZERO)
.select_on_focus(true)
.show(&mut child);
if frames < 2 {
field.response.request_focus();
typing = Some((text.clone(), frames + 1));
ui.data_mut(|m| m.insert_temp(typing_id, (text, frames + 1)));
} else if field.ending.is_some() || !field.editing || !enabled {
// Esc (or the slider turning off) keeps the old value; Return or clicking away applies
let cancelled = !enabled || field.cancelled();
if let Some(nv) = typed_value(spec, &text).filter(|nv| !cancelled && (nv - value).abs() > 1e-12) {
out.value = Some(nv);
out.drag_started = true;
out.drag_stopped = true;
v = nv;
}
ui.data_mut(|m| m.remove::<(String, u8)>(typing_id));
} else {
typing = Some((text.clone(), frames));
ui.data_mut(|m| m.insert_temp(typing_id, (text, frames)));
}
} else 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;
}
// Arrow keys work with either a hovered or a focused slider.
if enabled && out.value.is_none() && typing.is_none() && (ui.rect_contains_pointer(row) || resp.has_focus()) {
let (up, down, shift) = ui.input_mut(|i| {
let shift = i.modifiers.shift;
let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE };
(i.consume_key(m, egui::Key::ArrowUp), i.consume_key(m, egui::Key::ArrowDown), shift)
});
if up || down {
let nv = nudged(spec, value, if up { 1.0 } else { -1.0 } * if shift { 5.0 } else { 1.0 });
if (nv - value).abs() > 1e-12 {
out.value = Some(nv);
out.drag_started = true;
out.drag_stopped = true;
v = nv;
}
}
}
// Wheel nudges require an explicitly selected slider, unlike regular panel scrolling.
// Consume only the vertical component so a trackpad's horizontal scroll still works.
let wheel_id = id.with("wheel-rem");
if !resp.has_focus() {
ui.data_mut(|m| m.remove::<f32>(wheel_id));
} else if enabled && typing.is_none() && out.value.is_none() && ui.rect_contains_pointer(row) {
let delta = ui.input_mut(|i| {
let dy = i.smooth_scroll_delta.y;
if dy != 0.0 {
i.smooth_scroll_delta.y = 0.0;
}
dy
});
if delta != 0.0 {
let mut remainder = ui.data(|m| m.get_temp::<f32>(wheel_id)).unwrap_or(0.0);
let steps = wheel_nudges(&mut remainder, delta);
ui.data_mut(|m| m.insert_temp(wheel_id, remainder));
if steps != 0.0 {
let nv = nudged(spec, value, steps);
if (nv - value).abs() > 1e-12 {
out.value = Some(nv);
out.drag_started = true;
out.drag_stopped = true;
v = nv;
}
}
}
}
}
// 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, crate::i18n::tr(label_override.unwrap_or(spec.label)), t.font(12.5), text_c);
if typing.is_none() {
p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown_value(spec, v), t.font(12.5), text_c);
if enabled && value_resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
}
}
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
}
/// Convert vertical wheel movement to bounded slider steps, keeping sub-notch
/// trackpad movement between frames without amplifying a single large event.
fn wheel_nudges(remainder: &mut f32, delta: f32) -> f64 {
const POINTS_PER_STEP: f32 = 40.0;
let total = (*remainder + delta).clamp(-400.0, 400.0);
let steps = (total / POINTS_PER_STEP).trunc();
*remainder = total - steps * POINTS_PER_STEP;
f64::from(steps)
}
/// `value` moved by `steps` keyboard nudges: one nudge is about 1/200 of the range, a whole
/// number of the control's steps (exposure 0.05, most sliders 1, temperature 50 K).
pub fn nudged(spec: &ControlSpec, value: f64, steps: f64) -> f64 {
let step = spec.step.max(1e-9);
let unit = if spec.id == "wb.temp" { 50.0 } else { (((spec.max - spec.min) / 200.0 / step).round().max(1.0)) * step };
(((value + unit * steps) / step).round() * step).clamp(spec.min, spec.max)
}
/// 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 title = crate::i18n::tr(title);
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (r, resp) = ui.allocate_exact_size(vec2(w, 52.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title));
register(ui.ctx(), format!("section:{id}"), r);
let p = ui.painter();
if ui.rect_contains_pointer(r) {
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;
// shown while the pointer is anywhere on the header: `resp.hovered()` turns false as soon as the
// pointer is over the eye itself (the eye is on top), which hid the eye before it could be
// clicked (issue #316)
if let Some(on) = enabled
&& (ui.rect_contains_pointer(r) || !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 title = crate::i18n::tr(title);
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());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title));
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 label = if id.starts_with("labelSet-") { label } else { crate::i18n::tr(label) };
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());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, label));
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 label = crate::i18n::tr(label);
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 tooltip = crate::i18n::tr(tooltip);
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(size, if enabled { Sense::click() } else { Sense::hover() });
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, active, tooltip));
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());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, text));
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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nudges_are_a_sensible_step() {
let spec = |id, min, max, step| ControlSpec {
id,
label: "",
section: lightcraft_develop::controls::Section::Light,
min,
max,
default: 0.0,
step,
decimals: 2,
track: Track::Plain,
};
assert!((nudged(&spec("light.exposure", -5.0, 5.0, 0.01), 0.0, 1.0) - 0.05).abs() < 1e-9);
assert_eq!(nudged(&spec("light.contrast", -100.0, 100.0, 1.0), 10.0, -5.0), 5.0);
assert_eq!(nudged(&spec("wb.temp", 2000.0, 50000.0, 1.0), 6500.0, 1.0), 6550.0);
assert_eq!(nudged(&spec("light.contrast", -100.0, 100.0, 1.0), 99.0, 5.0), 100.0, "clamped");
}
#[test]
fn wheel_steps_accumulate_small_movements_and_preserve_direction() {
let mut remainder = 0.0;
assert_eq!(wheel_nudges(&mut remainder, 15.0), 0.0);
assert_eq!(wheel_nudges(&mut remainder, 25.0), 1.0);
assert_eq!(remainder, 0.0);
assert_eq!(wheel_nudges(&mut remainder, -100.0), -2.0);
assert_eq!(wheel_nudges(&mut remainder, -20.0), -1.0);
assert_eq!(remainder, 0.0);
}
/// Typed values (issue #322): signs, a decimal comma, units; on the control's steps, in range.
#[test]
fn typed_values_are_stepped_and_clamped() {
let exposure = lightcraft_develop::controls::find("light.exposure").unwrap();
let contrast = lightcraft_develop::controls::find("light.contrast").unwrap();
let temp = lightcraft_develop::controls::find("wb.temp").unwrap();
assert_eq!(typed_value(exposure, "1.5"), Some(1.5));
assert_eq!(typed_value(exposure, " +0,25 "), Some(0.25));
assert_eq!(typed_value(exposure, "-12"), Some(exposure.min), "clamped");
assert_eq!(typed_value(contrast, "33.4"), Some(33.0), "on the control's steps");
assert_eq!(typed_value(contrast, "-20 %"), Some(-20.0));
assert_eq!(typed_value(temp, "5600 K"), Some(5600.0));
for bad in ["", "abc", "1.2.3", "NaN", "inf", "--1"] {
assert_eq!(typed_value(contrast, bad), None, "{bad:?}");
}
}
}
#[cfg(test)]
mod access_tests {
use super::*;
/// Screen readers see the custom widgets: sliders by control name with their value, buttons
/// by label or tooltip.
#[test]
fn custom_widgets_describe_themselves() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
ctx.enable_accesskit();
let spec = lightcraft_develop::controls::find("light.exposure").unwrap();
let mut found = Vec::new();
for _ in 0..3 {
let out = ctx.run_ui(egui::RawInput::default(), |ui| {
let _ = slider(ui, spec, 0.5, true, None);
let _ = text_button(ui, "auto", "Auto", false);
let _ = icon_button(ui, "trash", Icon::Trash, vec2(24.0, 24.0), false, true, "Delete mask");
});
let mut out = out;
out.textures_delta.clear();
if let Some(update) = out.platform_output.accesskit_update.take() {
found = update
.nodes
.iter()
.map(|(_, n)| (format!("{:?}", n.role()), n.label().unwrap_or_default().to_string(), n.numeric_value()))
.collect();
}
}
let has = |role: &str, label: &str| found.iter().any(|(r, l, _)| r == role && l == label);
assert!(has("Slider", "Exposure"), "{found:?}");
assert!(found.iter().any(|(r, l, v)| r == "Slider" && l == "Exposure" && *v == Some(0.5)), "the slider's value: {found:?}");
assert!(has("Button", "Auto"), "{found:?}");
assert!(has("Button", "Delete mask"), "{found:?}");
}
}
/// `text` laid out on one line, cut short with … when it is wider than `max` (the galley's
/// `elided` says whether it was): for text painted into a fixed box (a tile, a row before its
/// buttons), which a plain `Painter::text` would draw past.
pub(crate) fn one_line(painter: &egui::Painter, text: &str, font: egui::FontId, color: Color32, max: f32) -> std::sync::Arc<egui::Galley> {
let mut job = egui::text::LayoutJob::simple_singleline(text.to_string(), font, color);
job.wrap = egui::text::TextWrapping::truncate_at_width(max.max(1.0));
painter.layout_job(job)
}
/// `text` shortened to fit `max` (as `width` measures it): leading folders drop first
/// (`Users/me/Pictures/Lightroom` → `…/Pictures/Lightroom`) so the end of a path, which says the
/// most, stays; a single name still too long loses its end (`2024-06-12 Tri…`: folders tend to
/// differ at the start). Cuts fall on characters. The result is never empty.
pub(crate) fn elide_head(text: &str, max: f32, width: impl Fn(&str) -> f32) -> String {
if width(text) <= max {
return text.to_string();
}
let mut rest = text;
while let Some(i) = rest.find('/') {
rest = rest.get(i + 1..).unwrap_or("");
let cand = format!("…/{rest}");
if width(&cand) <= max {
return cand;
}
}
let n = rest.chars().count();
for keep in (1..n).rev() {
let cand: String = rest.chars().take(keep).chain(std::iter::once('…')).collect();
if width(&cand) <= max {
return cand;
}
}
"…".to_string()
}