//! 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 { 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, 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); } }