Blacksite/crates/editor_ui/src/design_system/controls.rs
Rbanh 53dc1e44d8
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
feat: add production UI gallery and inspector system
2026-07-18 12:05:26 -04:00

382 lines
12 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.

//! Reusable Penpot-styled editor controls with no world or asset-store access.
use egui;
use egui_phosphor_icons::{icons, Icon};
use std::ops::RangeInclusive;
use super::palette;
use super::typography::TypeRole;
// Penpot's scalar component keeps a six-pixel leading inset inside its value-group frame. The
// exported child geometry is authoritative here; the group's transparent parent frame is six
// pixels narrower than its visible children and must not be mistaken for the control width.
const SCALAR_TRACK_X: f32 = 6.0;
const SCALAR_NUMBER_GAP: f32 = 6.0;
const SCALAR_TRACK_INTERACTION_HEIGHT: f32 = 10.0;
const SCALAR_NUMBER_WIDTH: f32 = 33.0;
const SCALAR_THUMB_RADIUS: f32 = 4.0;
pub fn section_frame(ui: &egui::Ui) -> egui::Frame {
let palette = palette(ui);
egui::Frame::new()
.fill(palette.section)
.stroke(egui::Stroke::new(1.0_f32, palette.border))
.corner_radius(egui::CornerRadius::same(5))
}
pub fn section_header(ui: &mut egui::Ui, title: &str, open: &mut bool) -> egui::Response {
section_header_with_summary(ui, title, None, open)
}
pub fn section_header_with_summary(
ui: &mut egui::Ui,
title: &str,
summary: Option<&str>,
open: &mut bool,
) -> egui::Response {
let palette = palette(ui);
let (rect, response) = ui.allocate_exact_size(
egui::vec2(
ui.available_width().max(1.0),
super::INPUT_SECTION_HEADER_HEIGHT,
),
egui::Sense::click(),
);
ui.painter().rect_filled(rect, 4.0, palette.elevated);
ui.painter().text(
rect.left_center() + egui::vec2(12.0, 0.0),
egui::Align2::CENTER_CENTER,
if *open {
icons::CARET_DOWN.as_str()
} else {
icons::CARET_RIGHT.as_str()
},
egui::FontId::new(10.0, egui::FontFamily::Name("phosphor-bold".into())),
palette.text_secondary,
);
ui.painter().text(
rect.left_center() + egui::vec2(26.0, 0.0),
egui::Align2::LEFT_CENTER,
title,
TypeRole::Section.font(),
palette.text_primary,
);
if let Some(summary) = summary {
ui.painter().text(
rect.right_center() - egui::vec2(12.0, 0.0),
egui::Align2::RIGHT_CENTER,
summary,
TypeRole::Small.font(),
palette.text_muted,
);
}
if response.clicked() {
*open = !*open;
}
response
}
/// Compose a Phosphor glyph and Source Sans label without asking either font to render the
/// other's codepoints. This is the canonical path for icon-plus-text controls in a design-system
/// scope.
pub fn icon_label(
icon: Icon,
label: &str,
role: TypeRole,
icon_size: f32,
color: egui::Color32,
) -> egui::WidgetText {
let mut job = egui::text::LayoutJob::default();
job.append(
icon.as_str(),
0.0,
egui::TextFormat {
font_id: egui::FontId::new(icon_size, egui::FontFamily::Name("phosphor-bold".into())),
color,
..Default::default()
},
);
if !label.is_empty() {
job.append(
label,
6.0,
egui::TextFormat {
font_id: role.font(),
color,
..Default::default()
},
);
}
job.into()
}
pub fn icon_button(ui: &mut egui::Ui, icon: Icon, tooltip: &str, enabled: bool) -> egui::Response {
let palette = palette(ui);
let response = ui.add_enabled(
enabled,
egui::Button::new(
egui::RichText::new(icon.as_str())
.font(egui::FontId::new(
14.0,
egui::FontFamily::Name("phosphor-bold".into()),
))
.color(palette.text_secondary),
)
.min_size(egui::vec2(24.0, 24.0))
.fill(palette.control)
.stroke(egui::Stroke::new(1.0_f32, palette.border))
.corner_radius(egui::CornerRadius::same(4)),
);
response.on_hover_text(tooltip)
}
pub fn status(ui: &mut egui::Ui, icon: Icon, color: egui::Color32, label: &str, tooltip: &str) {
let palette = palette(ui);
let mut job = egui::text::LayoutJob::default();
job.append(
icon.as_str(),
0.0,
egui::TextFormat {
font_id: egui::FontId::new(12.0, egui::FontFamily::Name("phosphor-bold".into())),
color,
..Default::default()
},
);
job.append(
label,
5.0,
egui::TextFormat {
font_id: TypeRole::Small.font(),
color: palette.text_secondary,
..Default::default()
},
);
let response = ui.add(egui::Label::new(job).sense(egui::Sense::hover()));
response.on_hover_text(tooltip);
}
pub fn switch(ui: &mut egui::Ui, value: &mut bool) -> egui::Response {
let palette = palette(ui);
let (rect, mut response) = ui.allocate_exact_size(egui::vec2(31.0, 16.0), egui::Sense::click());
if response.clicked() {
*value = !*value;
response.mark_changed();
}
let fill = if *value {
palette.accent_dark
} else {
palette.control
};
let stroke = if *value {
palette.accent
} else {
palette.border_strong
};
ui.painter().rect(
rect,
8.0,
fill,
egui::Stroke::new(1.0_f32, stroke),
egui::StrokeKind::Inside,
);
let x = if *value {
rect.right() - 8.0
} else {
rect.left() + 8.0
};
ui.painter()
.circle_filled(egui::pos2(x, rect.center().y), 5.0, palette.text_primary);
response
}
/// Fixed 128×24 two-option control used by the Penpot material Surface section.
///
/// Returning the selected side keeps the control independent from material state and prevents
/// stock `selectable_label` padding from changing its geometry.
pub fn two_option_segmented(
ui: &mut egui::Ui,
left_label: &str,
right_label: &str,
right_selected: bool,
) -> Option<bool> {
const WIDTH: f32 = 128.0;
const HEIGHT: f32 = 24.0;
const OPTION_WIDTH: f32 = WIDTH * 0.5;
let palette = palette(ui);
let (rect, _) = ui.allocate_exact_size(egui::vec2(WIDTH, HEIGHT), egui::Sense::hover());
ui.painter().rect(
rect,
4.0,
palette.control,
egui::Stroke::new(1.0_f32, palette.border),
egui::StrokeKind::Inside,
);
let left = egui::Rect::from_min_size(rect.min, egui::vec2(OPTION_WIDTH, HEIGHT));
let right = egui::Rect::from_min_size(
rect.min + egui::vec2(OPTION_WIDTH, 0.0),
egui::vec2(OPTION_WIDTH, HEIGHT),
);
let mut selection = None;
for (option_rect, label, selected, value) in [
(left, left_label, !right_selected, false),
(right, right_label, right_selected, true),
] {
let response = ui.interact(
option_rect,
ui.make_persistent_id(("two_option_segment", label)),
egui::Sense::click(),
);
let fill = if selected {
palette.accent_dark
} else if response.hovered() {
palette.elevated
} else {
palette.control
};
ui.painter().rect_filled(option_rect.shrink(1.0), 3.0, fill);
if selected {
ui.painter().rect_stroke(
option_rect,
3.0,
egui::Stroke::new(1.0_f32, palette.accent),
egui::StrokeKind::Inside,
);
}
ui.painter().text(
option_rect.center(),
egui::Align2::CENTER_CENTER,
label,
TypeRole::Control.font(),
if selected {
palette.text_primary
} else {
palette.text_secondary
},
);
if response.clicked() && right_selected != value {
selection = Some(value);
}
}
selection
}
/// Exact Penpot scalar editor used by material inputs.
///
/// The track and numeric field are painted as independent controls so egui's stock slider
/// padding cannot move the thumb into the value field at the reference width.
pub fn scalar_control(
ui: &mut egui::Ui,
salt: impl std::hash::Hash,
value: &mut f32,
range: RangeInclusive<f32>,
max_decimals: usize,
) -> egui::Response {
let palette = palette(ui);
let before = *value;
// The final Penpot component deliberately stretches only the track at the compact
// breakpoint: 93.8 px at the 569 px reference and 160.3 px at the 369 px reference.
// The numeric field remains a stable 33 px in both modes, after a six-pixel leading inset
// and a six-pixel track-to-number gap.
let width = ui.available_width().max(1.0);
let (rect, base_response) =
ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover());
let number_width = SCALAR_NUMBER_WIDTH.min(width);
let number_x = (width - number_width).max(0.0);
let track_width = (number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP).max(1.0);
let slider_rect = egui::Rect::from_min_size(
rect.min + egui::vec2(SCALAR_TRACK_X, 6.0),
egui::vec2(track_width, SCALAR_TRACK_INTERACTION_HEIGHT),
);
let track_rect =
egui::Rect::from_center_size(slider_rect.center(), egui::vec2(track_width, 3.0));
let slider_response = ui.interact(
slider_rect,
ui.make_persistent_id(("penpot_scalar", salt)),
egui::Sense::click_and_drag(),
);
if slider_response.clicked() || slider_response.dragged() {
if let Some(pointer) = slider_response.interact_pointer_pos() {
let start = *range.start();
let end = *range.end();
let normalized = ((pointer.x - track_rect.left()) / track_rect.width()).clamp(0.0, 1.0);
*value = egui::lerp(start..=end, normalized);
}
}
let start = *range.start();
let end = *range.end();
let normalized = if end > start {
((*value - start) / (end - start)).clamp(0.0, 1.0)
} else {
0.0
};
ui.painter().rect_filled(track_rect, 2.0, palette.border);
let fill = egui::Rect::from_min_max(
track_rect.left_top(),
egui::pos2(
egui::lerp(track_rect.x_range(), normalized),
track_rect.bottom(),
),
);
ui.painter().rect_filled(fill, 2.0, palette.accent);
let thumb_center = egui::pos2(
egui::lerp(track_rect.x_range(), normalized),
track_rect.center().y,
);
ui.painter().circle(
thumb_center,
SCALAR_THUMB_RADIUS,
palette.text_primary,
egui::Stroke::new(1.0_f32, palette.accent),
);
let number_rect = egui::Rect::from_min_size(
rect.min + egui::vec2(number_x, 0.0),
egui::vec2(number_width, 22.0),
);
let number_response = ui.put(
number_rect,
egui::DragValue::new(value)
.speed(0.01)
.range(range)
.max_decimals(max_decimals),
);
let mut response = base_response.union(slider_response).union(number_response);
if *value != before {
response.mark_changed();
}
response
}
#[cfg(test)]
mod scalar_tests {
use super::*;
#[test]
fn penpot_scalar_geometry_matches_the_current_export() {
let wide_width = 138.8;
let wide_number_x = wide_width - SCALAR_NUMBER_WIDTH;
let wide_track = wide_number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP;
assert!((wide_track - 93.8).abs() < 0.001);
let compact_width = 205.3;
let compact_number_x = compact_width - SCALAR_NUMBER_WIDTH;
let compact_track = compact_number_x - SCALAR_TRACK_X - SCALAR_NUMBER_GAP;
assert!((compact_track - 160.3).abs() < 0.001);
assert_eq!(SCALAR_TRACK_INTERACTION_HEIGHT, 10.0);
assert_eq!(SCALAR_THUMB_RADIUS, 4.0);
}
}
#[cfg(test)]
mod segmented_tests {
#[test]
fn penpot_segmented_control_has_two_fixed_halves() {
const WIDTH: f32 = 128.0;
const OPTION_WIDTH: f32 = WIDTH * 0.5;
assert_eq!(OPTION_WIDTH, 64.0);
assert_eq!(OPTION_WIDTH * 2.0, WIDTH);
}
}