mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-01 16:24:51 +00:00
328 lines
12 KiB
Rust
328 lines
12 KiB
Rust
//! Independent, font-free reference painting for the shared controls' public visual contracts.
|
|
//! Reference paths never call craft-ui or derive their expected rectangles from its responses.
|
|
|
|
use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle};
|
|
use craft_ui::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split_rect};
|
|
use craft_ui::tabs::Tab;
|
|
use egui::{Align, Color32, CornerRadius, Id, Layout, Painter, Rect, Response, Stroke, StrokeKind, Ui, UiBuilder, Vec2, pos2, vec2};
|
|
|
|
pub const CANVAS: Vec2 = vec2(128.0, 72.0);
|
|
pub const GAP: f32 = 8.0;
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub enum Theme {
|
|
Light,
|
|
Dark,
|
|
}
|
|
|
|
impl Theme {
|
|
pub fn name(self) -> &'static str {
|
|
match self {
|
|
Self::Light => "light",
|
|
Self::Dark => "dark",
|
|
}
|
|
}
|
|
|
|
pub fn egui(self) -> egui::Theme {
|
|
match self {
|
|
Self::Light => egui::Theme::Light,
|
|
Self::Dark => egui::Theme::Dark,
|
|
}
|
|
}
|
|
|
|
pub fn surface(self) -> Color32 {
|
|
match self {
|
|
Self::Light => Color32::from_rgb(248, 250, 252),
|
|
Self::Dark => Color32::from_rgb(17, 24, 39),
|
|
}
|
|
}
|
|
|
|
pub fn palette(self) -> Palette {
|
|
let rgb = |[r, g, b]: [u8; 3]| Color32::from_rgb(r, g, b);
|
|
let colors = match self {
|
|
Self::Light => [
|
|
[226, 232, 240],
|
|
[186, 230, 253],
|
|
[125, 211, 252],
|
|
[37, 99, 235],
|
|
[148, 163, 184],
|
|
[15, 23, 42],
|
|
[12, 74, 110],
|
|
[8, 47, 73],
|
|
[220, 38, 38],
|
|
],
|
|
Self::Dark => [
|
|
[31, 41, 55],
|
|
[55, 65, 81],
|
|
[75, 85, 99],
|
|
[67, 56, 202],
|
|
[100, 116, 139],
|
|
[226, 232, 240],
|
|
[243, 244, 246],
|
|
[255, 255, 255],
|
|
[56, 189, 248],
|
|
],
|
|
};
|
|
Palette {
|
|
normal: rgb(colors[0]),
|
|
hovered: rgb(colors[1]),
|
|
pressed: rgb(colors[2]),
|
|
selected: rgb(colors[3]),
|
|
border: rgb(colors[4]),
|
|
icon: rgb(colors[5]),
|
|
hover_icon: rgb(colors[6]),
|
|
press_icon: rgb(colors[7]),
|
|
focus: rgb(colors[8]),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct Palette {
|
|
pub normal: Color32,
|
|
pub hovered: Color32,
|
|
pub pressed: Color32,
|
|
pub selected: Color32,
|
|
pub border: Color32,
|
|
pub icon: Color32,
|
|
pub hover_icon: Color32,
|
|
pub press_icon: Color32,
|
|
pub focus: Color32,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum State {
|
|
Normal,
|
|
Hover,
|
|
Press,
|
|
Selected,
|
|
SelectedPress,
|
|
Disabled,
|
|
Focus,
|
|
}
|
|
|
|
impl State {
|
|
pub const ALL: [Self; 7] = [Self::Normal, Self::Hover, Self::Press, Self::Selected, Self::SelectedPress, Self::Disabled, Self::Focus];
|
|
|
|
pub fn selected(self) -> bool {
|
|
matches!(self, Self::Selected | Self::SelectedPress)
|
|
}
|
|
|
|
pub fn pressed(self) -> bool {
|
|
matches!(self, Self::Press | Self::SelectedPress)
|
|
}
|
|
|
|
pub fn pointer(self) -> bool {
|
|
matches!(self, Self::Hover | Self::Press | Self::SelectedPress | Self::Disabled)
|
|
}
|
|
|
|
pub fn enabled(self) -> bool {
|
|
self != Self::Disabled
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub enum Component {
|
|
Button,
|
|
SingleShapeButton,
|
|
Tab,
|
|
HorizontalSplit,
|
|
VerticalSplit,
|
|
}
|
|
|
|
impl Component {
|
|
pub const ALL: [Self; 5] = [Self::Button, Self::SingleShapeButton, Self::Tab, Self::HorizontalSplit, Self::VerticalSplit];
|
|
|
|
pub fn is_split(self) -> bool {
|
|
matches!(self, Self::HorizontalSplit | Self::VerticalSplit)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct Case {
|
|
pub component: Component,
|
|
pub state: State,
|
|
pub theme: Theme,
|
|
pub scale: f32,
|
|
pub compact: bool,
|
|
}
|
|
|
|
impl Case {
|
|
pub fn name(self) -> String {
|
|
format!("{:?}-{:?}-{}-{}-{}x", self.component, self.state, self.theme.name(), if self.compact { "compact" } else { "normal" }, self.scale)
|
|
}
|
|
|
|
pub fn size(self) -> Vec2 {
|
|
match (self.component, self.compact) {
|
|
(Component::Tab, false) => vec2(44.0, 28.0),
|
|
(Component::Tab, true) => vec2(22.0, 12.0),
|
|
(_, false) => vec2(36.0, 28.0),
|
|
(_, true) => vec2(18.0, 12.0),
|
|
}
|
|
}
|
|
|
|
pub fn control_rect(self) -> Rect {
|
|
if self.component.is_split() { reference_split(self).1 } else { Rect::from_min_size(pos2(12.0, 12.0), self.size()) }
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Mutation {
|
|
None,
|
|
Color,
|
|
Spacing,
|
|
Stroke,
|
|
}
|
|
|
|
pub fn button_style(theme: Theme) -> IconButtonStyle {
|
|
let p = theme.palette();
|
|
let visual = |fill, icon| ButtonVisuals { fill, icon, stroke: Stroke::new(1.0, p.border) };
|
|
IconButtonStyle {
|
|
normal: visual(p.normal, p.icon),
|
|
hovered: visual(p.hovered, p.hover_icon),
|
|
pressed: Some(visual(p.pressed, p.press_icon)),
|
|
selected: visual(p.selected, Color32::WHITE),
|
|
corner_radius: CornerRadius::same(3),
|
|
focus_stroke: Stroke::new(1.0, p.focus),
|
|
}
|
|
}
|
|
|
|
pub fn icon(painter: &Painter, rect: Rect, color: Color32, compact: bool) {
|
|
let half = if compact { vec2(3.0, 2.0) } else { vec2(6.0, 4.0) };
|
|
painter.line_segment([rect.center() - half, rect.center() + half], Stroke::new(1.0, color));
|
|
painter.line_segment([rect.center() + vec2(-half.x, half.y), rect.center() + vec2(half.x, -half.y)], Stroke::new(1.0, color));
|
|
}
|
|
|
|
fn tab_body(ui: &Ui, response: &Response, selected: bool, palette: Palette, compact: bool) {
|
|
let (fill, tint) = if selected {
|
|
(palette.selected, Color32::WHITE)
|
|
} else if response.is_pointer_button_down_on() {
|
|
(palette.pressed, palette.press_icon)
|
|
} else if response.hovered() {
|
|
(palette.hovered, palette.hover_icon)
|
|
} else {
|
|
(palette.normal, palette.icon)
|
|
};
|
|
ui.painter().rect_filled(response.rect, 0.0, fill);
|
|
ui.painter().rect_stroke(response.rect, 0.0, Stroke::new(1.0, palette.border), StrokeKind::Inside);
|
|
icon(ui.painter(), response.rect, tint, compact);
|
|
}
|
|
|
|
/// Actual production components, driven through egui responses and app-supplied fixture styles.
|
|
pub fn actual(ui: &mut Ui, case: Case, mutation: Mutation) -> Vec<Response> {
|
|
let p = case.theme.palette();
|
|
if case.component.is_split() {
|
|
let axis = if matches!(case.component, Component::HorizontalSplit) { SplitAxis::Horizontal } else { SplitAxis::Vertical };
|
|
let area = Rect::from_min_size(pos2(12.0, 12.0), if case.compact { vec2(52.0, 20.0) } else { vec2(104.0, 44.0) });
|
|
let split =
|
|
split_rect(area, axis, SplitSize::Ratio(if case.compact { 0.5 } else { 0.375 }), if case.compact { 3.0 } else { 4.0 }, 4.0).unwrap();
|
|
ui.painter().rect_filled(split.first, 0.0, p.normal);
|
|
ui.painter().rect_filled(split.second, 0.0, p.hovered);
|
|
let response = ui
|
|
.add_enabled_ui(case.state.enabled(), |ui| {
|
|
ResizeHandle::new(
|
|
Id::new("visual-divider"),
|
|
axis,
|
|
split.divider,
|
|
"Resize panes",
|
|
ResizeHandleStyle { hovered: p.hovered, dragged: p.pressed, focused: p.focus },
|
|
)
|
|
.hit_margin(0.0)
|
|
.show(ui)
|
|
.response
|
|
})
|
|
.inner;
|
|
return vec![response];
|
|
}
|
|
let spacing = GAP + if mutation == Mutation::Spacing { 1.0 } else { 0.0 };
|
|
let mut style = button_style(case.theme);
|
|
if mutation == Mutation::Color {
|
|
style.normal.fill = Color32::from_rgb(200, 60, 130);
|
|
}
|
|
if mutation == Mutation::Stroke {
|
|
style.normal.stroke.width = 2.0;
|
|
}
|
|
let mut responses = Vec::new();
|
|
ui.scope_builder(
|
|
UiBuilder::new().max_rect(Rect::from_min_size(pos2(12.0, 12.0), CANVAS - vec2(24.0, 24.0))).layout(Layout::left_to_right(Align::Min)),
|
|
|ui| {
|
|
ui.spacing_mut().item_spacing.x = spacing;
|
|
ui.add_enabled_ui(case.state.enabled(), |ui| {
|
|
for index in 0..2 {
|
|
let selected = index == 0 && case.state.selected();
|
|
let response = if matches!(case.component, Component::Tab) {
|
|
let rect = Rect::from_min_size(pos2(12.0 + index as f32 * (case.size().x + spacing), 12.0), case.size());
|
|
Tab::new(Id::new(("visual-tab", index)), "Tab", selected)
|
|
.focus_stroke(style.focus_stroke)
|
|
.show_at(ui, rect, |ui, r| tab_body(ui, r, selected, p, case.compact))
|
|
} else {
|
|
let button = IconButton::new("Action", case.size(), &style).selected(selected);
|
|
let button = if matches!(case.component, Component::SingleShapeButton) { button.single_shape_frame() } else { button };
|
|
button.show(ui, |ui, rect, tint| icon(ui.painter(), rect, tint, case.compact))
|
|
};
|
|
responses.push(response);
|
|
}
|
|
});
|
|
},
|
|
);
|
|
responses
|
|
}
|
|
|
|
/// Fixed reference geometry, written independently of split_rect's implementation.
|
|
fn reference_split(case: Case) -> (Rect, Rect, Rect) {
|
|
let rect = |x, y, w, h| Rect::from_min_size(pos2(x, y), vec2(w, h));
|
|
match (case.component, case.compact) {
|
|
(Component::HorizontalSplit, false) => (rect(12.0, 12.0, 37.5, 44.0), rect(49.5, 12.0, 4.0, 44.0), rect(53.5, 12.0, 62.5, 44.0)),
|
|
(Component::HorizontalSplit, true) => (rect(12.0, 12.0, 24.5, 20.0), rect(36.5, 12.0, 3.0, 20.0), rect(39.5, 12.0, 24.5, 20.0)),
|
|
(Component::VerticalSplit, false) => (rect(12.0, 12.0, 104.0, 15.0), rect(12.0, 27.0, 104.0, 4.0), rect(12.0, 31.0, 104.0, 25.0)),
|
|
(Component::VerticalSplit, true) => (rect(12.0, 12.0, 52.0, 8.5), rect(12.0, 20.5, 52.0, 3.0), rect(12.0, 23.5, 52.0, 8.5)),
|
|
_ => panic!("split fixture required"),
|
|
}
|
|
}
|
|
|
|
/// Baseline painting uses only egui shapes, literal fixture geometry, and the specified state.
|
|
pub fn reference(ui: &Ui, case: Case) {
|
|
let p = case.theme.palette();
|
|
if case.component.is_split() {
|
|
let (first, divider, second) = reference_split(case);
|
|
ui.painter().rect_filled(first, 0.0, p.normal);
|
|
ui.painter().rect_filled(second, 0.0, p.hovered);
|
|
let fill = match case.state {
|
|
State::Hover => Some(p.hovered),
|
|
State::Press => Some(p.pressed),
|
|
State::Focus => Some(p.focus),
|
|
_ => None,
|
|
};
|
|
if let Some(fill) = fill {
|
|
ui.painter().rect_filled(divider, 0.0, fill);
|
|
}
|
|
return;
|
|
}
|
|
let mut painter = ui.painter().clone();
|
|
if case.state == State::Disabled {
|
|
// The fixture's known disabled alpha. Do not ask the actual widget how it was painted.
|
|
painter.multiply_opacity(0.4);
|
|
}
|
|
for index in 0..2 {
|
|
let state = if index == 0 { case.state } else { State::Normal };
|
|
let (fill, tint) = match state {
|
|
State::Selected | State::SelectedPress => (p.selected, Color32::WHITE),
|
|
State::Press => (p.pressed, p.press_icon),
|
|
State::Hover => (p.hovered, p.hover_icon),
|
|
_ => (p.normal, p.icon),
|
|
};
|
|
let rect = Rect::from_min_size(pos2(12.0 + index as f32 * (case.size().x + GAP), 12.0), case.size());
|
|
let radius = if matches!(case.component, Component::Tab) { 0 } else { 3 };
|
|
if matches!(case.component, Component::SingleShapeButton) {
|
|
painter.rect(rect, radius, fill, Stroke::new(1.0, p.border), StrokeKind::Inside);
|
|
} else {
|
|
painter.rect_filled(rect, radius, fill);
|
|
painter.rect_stroke(rect, radius, Stroke::new(1.0, p.border), StrokeKind::Inside);
|
|
}
|
|
icon(&painter, rect, tint, case.compact);
|
|
if state == State::Focus {
|
|
painter.rect_stroke(rect.shrink(2.0), radius, Stroke::new(1.0, p.focus), StrokeKind::Inside);
|
|
}
|
|
}
|
|
}
|