382 lines
12 KiB
Rust
382 lines
12 KiB
Rust
//! 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);
|
||
}
|
||
}
|