253 lines
8.6 KiB
Rust
253 lines
8.6 KiB
Rust
use graphite_editor::messages::layout::utility_types::widget_prelude::*;
|
||
use iced_widget::core::{Alignment, Background, Border, Color, Length, Theme};
|
||
use iced_widget::image::Handle as ImageHandle;
|
||
use iced_widget::{Space, button, column, container, image, row, text};
|
||
use include_dir::{Dir, include_dir};
|
||
use std::collections::HashMap;
|
||
use std::sync::{LazyLock, Mutex};
|
||
|
||
const SURFACE: Color = Color::from_rgba(0.10, 0.10, 0.10, 1.0);
|
||
const SURFACE_HOVER: Color = Color::from_rgba(0.16, 0.16, 0.16, 1.0);
|
||
const SURFACE_PRESSED: Color = Color::from_rgba(0.20, 0.20, 0.20, 1.0);
|
||
const BORDER: Color = Color::from_rgba(0.28, 0.28, 0.28, 1.0);
|
||
const TEXT_PRIMARY: Color = Color::from_rgba(0.86, 0.86, 0.86, 1.0);
|
||
|
||
pub fn graphite_button(_: &Theme, status: button::Status) -> button::Style {
|
||
let bg = match status {
|
||
button::Status::Hovered => SURFACE_HOVER,
|
||
button::Status::Pressed => SURFACE_PRESSED,
|
||
button::Status::Disabled => Color { a: 0.5, ..SURFACE },
|
||
button::Status::Active => SURFACE,
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(bg)),
|
||
text_color: TEXT_PRIMARY,
|
||
border: Border {
|
||
color: BORDER,
|
||
width: 1.0,
|
||
radius: 2.0.into(),
|
||
},
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
pub fn graphite_flush_button(_: &Theme, status: button::Status) -> button::Style {
|
||
let bg = match status {
|
||
button::Status::Hovered => Some(Background::Color(SURFACE_HOVER)),
|
||
button::Status::Pressed => Some(Background::Color(SURFACE_PRESSED)),
|
||
_ => None,
|
||
};
|
||
button::Style {
|
||
background: bg,
|
||
text_color: TEXT_PRIMARY,
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
use crate::app::{Element, Message};
|
||
|
||
static FRONTEND_ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../frontend/assets");
|
||
|
||
static IMAGE_CACHE: LazyLock<Mutex<HashMap<String, ImageHandle>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||
|
||
fn lookup_image_handle(name: &str) -> Option<ImageHandle> {
|
||
let filename = camel_to_kebab_png(name);
|
||
let mut cache = IMAGE_CACHE.lock().ok()?;
|
||
if let Some(handle) = cache.get(&filename) {
|
||
return Some(handle.clone());
|
||
}
|
||
let bytes = FRONTEND_ASSETS.get_file(&filename)?.contents();
|
||
let decoded = match ::image::load_from_memory(bytes) {
|
||
Ok(img) => img,
|
||
Err(e) => {
|
||
tracing::warn!(filename = %filename, error = %e, "image decode failed");
|
||
return None;
|
||
}
|
||
};
|
||
let rgba = decoded.to_rgba8();
|
||
let (w, h) = (rgba.width(), rgba.height());
|
||
let handle = ImageHandle::from_rgba(w, h, rgba.into_raw());
|
||
cache.insert(filename, handle.clone());
|
||
Some(handle)
|
||
}
|
||
|
||
fn camel_to_kebab_png(name: &str) -> String {
|
||
let mut out = String::new();
|
||
for (i, c) in name.chars().enumerate() {
|
||
if c.is_ascii_uppercase() && i > 0 {
|
||
out.push('-');
|
||
}
|
||
out.push(c.to_ascii_lowercase());
|
||
}
|
||
out.push_str(".png");
|
||
out
|
||
}
|
||
|
||
fn parse_px(value: &str) -> Option<f32> {
|
||
value.trim_end_matches("px").parse::<f32>().ok()
|
||
}
|
||
|
||
pub fn render_layout(target: LayoutTarget, layout: &Layout) -> Element<'_, Message> {
|
||
let mut col = column![].spacing(4);
|
||
for group in &layout.0 {
|
||
col = col.push(render_group(target, group));
|
||
}
|
||
col.into()
|
||
}
|
||
|
||
fn render_group(target: LayoutTarget, group: &LayoutGroup) -> Element<'_, Message> {
|
||
match group {
|
||
LayoutGroup::Column(WidgetColumn { widgets }) => {
|
||
let mut col = column![].spacing(4);
|
||
for instance in widgets {
|
||
col = col.push(widget_to_element(target, instance.widget_id, &instance.widget));
|
||
}
|
||
col.into()
|
||
}
|
||
LayoutGroup::Row(WidgetRow { widgets }) => {
|
||
let mut r = row![].spacing(4).align_y(Alignment::Center).width(Length::Fill);
|
||
for instance in widgets {
|
||
r = r.push(widget_to_element(target, instance.widget_id, &instance.widget));
|
||
}
|
||
r.into()
|
||
}
|
||
LayoutGroup::Section(WidgetSection { name, layout, .. }) => {
|
||
let mut col = column![text(name.as_str()).size(12)].spacing(4);
|
||
for child in &layout.0 {
|
||
col = col.push(render_group(target, child));
|
||
}
|
||
container(col).padding(6).into()
|
||
}
|
||
LayoutGroup::Table(WidgetTable { rows, .. }) => {
|
||
let mut col = column![].spacing(2);
|
||
for table_row in rows {
|
||
let mut r = row![].spacing(4);
|
||
for instance in table_row {
|
||
r = r.push(widget_to_element(target, instance.widget_id, &instance.widget));
|
||
}
|
||
col = col.push(r);
|
||
}
|
||
col.into()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// maps every Widget variant to an ICED element. labels appended for all available items, not just implemented ones.
|
||
fn widget_to_element(target: LayoutTarget, widget_id: WidgetId, widget: &Widget) -> Element<'_, Message> {
|
||
let click = |value: serde_json::Value| Message::WidgetClicked { layout_target: target, widget_id, value };
|
||
let json = |w: &dyn erased_json::Erased| w.to_json();
|
||
|
||
match widget {
|
||
Widget::TextLabel(w) => text(w.value.as_str()).size(13).into(),
|
||
Widget::IconLabel(w) => text(format!("[{}]", w.icon)).size(13).into(),
|
||
Widget::ShortcutLabel(_) => text("⌘").size(11).into(),
|
||
Widget::Separator(w) => match w.direction {
|
||
SeparatorDirection::Horizontal => Space::new().width(Length::Fixed(8.0)).into(),
|
||
SeparatorDirection::Vertical => Space::new().height(Length::Fixed(8.0)).into(),
|
||
},
|
||
Widget::IconButton(w) => button(text(format!("[{}]", w.icon))).on_press(click(json(w))).style(graphite_button).into(),
|
||
Widget::TextButton(w) => {
|
||
let mut b = button(text(w.label.as_str()).size(13)).on_press(click(serde_json::Value::Array(Vec::new())));
|
||
b = if w.flush { b.style(graphite_flush_button) } else { b.style(graphite_button) };
|
||
if w.min_width > 0 {
|
||
b = b.width(Length::FillPortion(1));
|
||
}
|
||
b.into()
|
||
}
|
||
Widget::PopoverButton(w) => {
|
||
let label = w.icon.as_deref().unwrap_or("▾");
|
||
button(text(format!("[{label}]"))).on_press(click(json(w))).style(graphite_button).into()
|
||
}
|
||
Widget::CheckboxInput(w) => {
|
||
let mark = if w.checked { "[x]" } else { "[ ]" };
|
||
text(mark).size(13).into()
|
||
}
|
||
Widget::RadioInput(w) => {
|
||
let mut r = row![].spacing(4);
|
||
for entry in &w.entries {
|
||
let label = if !entry.label.is_empty() {
|
||
entry.label.as_str()
|
||
} else if let Some(icon) = entry.icon.as_deref() {
|
||
icon
|
||
} else {
|
||
"●"
|
||
};
|
||
r = r.push(button(text(label).size(12)).on_press(click(json(w))).style(graphite_button));
|
||
}
|
||
r.into()
|
||
}
|
||
Widget::DropdownInput(w) => {
|
||
let current = w
|
||
.selected_index
|
||
.and_then(|i| w.entries.iter().flatten().nth(i as usize))
|
||
.map(|entry| entry.label.as_str())
|
||
.unwrap_or("▾");
|
||
button(text(format!("{current} ▾")).size(12)).on_press(click(json(w))).style(graphite_button).into()
|
||
}
|
||
Widget::NumberInput(w) => {
|
||
let body = match w.value {
|
||
Some(v) => format!("{v}"),
|
||
None => String::from("—"),
|
||
};
|
||
let body = if !w.label.is_empty() { format!("{}: {body}", w.label) } else { body };
|
||
text(body).size(12).into()
|
||
}
|
||
Widget::TextInput(w) => text(w.value.as_str()).size(12).into(),
|
||
Widget::TextAreaInput(w) => text(w.value.as_str()).size(12).into(),
|
||
Widget::ColorInput(_) => text("◼ color").size(12).into(),
|
||
Widget::ColorComparisonInput(_) => text("◼◼").size(12).into(),
|
||
Widget::ColorPresetsInput(_) => text("◼◼◼").size(12).into(),
|
||
Widget::SpectrumInput(_) => text("[spectrum]").size(11).into(),
|
||
Widget::VisualColorPickersInput(_) => text("[picker]").size(11).into(),
|
||
Widget::WorkingColorsInput(_) => text("◼/◻").size(12).into(),
|
||
Widget::ReferencePointInput(_) => text("·").size(13).into(),
|
||
Widget::BreadcrumbTrailButtons(w) => {
|
||
let mut r = row![].spacing(4);
|
||
for label in &w.labels {
|
||
r = r.push(text(label.as_str()).size(12));
|
||
r = r.push(text("›").size(12));
|
||
}
|
||
r.into()
|
||
}
|
||
Widget::ParameterExposeButton(w) => button(text("●").size(11)).on_press(click(json(w))).style(graphite_button).into(),
|
||
Widget::NodeCatalog(_) => text("[node catalog]").size(11).into(),
|
||
Widget::ImageButton(w) => match lookup_image_handle(&w.image) {
|
||
Some(handle) => {
|
||
let mut img = image(handle);
|
||
if w.width.is_some() {
|
||
img = img.width(Length::FillPortion(1));
|
||
} else if let Some(height) = w.height.as_deref().and_then(parse_px) {
|
||
img = img.height(Length::Fixed(height));
|
||
}
|
||
img.into()
|
||
}
|
||
None => text(format!("[{}]", w.image)).size(11).into(),
|
||
},
|
||
Widget::ImageLabel(w) => match lookup_image_handle(&w.url) {
|
||
Some(handle) => {
|
||
let mut img = image(handle);
|
||
if let Some(width) = w.width.as_deref().and_then(parse_px) {
|
||
img = img.width(Length::Fixed(width));
|
||
}
|
||
if let Some(height) = w.height.as_deref().and_then(parse_px) {
|
||
img = img.height(Length::Fixed(height));
|
||
}
|
||
img.into()
|
||
}
|
||
None => text(format!("[{}]", w.url)).size(11).into(),
|
||
},
|
||
}
|
||
}
|
||
|
||
mod erased_json {
|
||
pub trait Erased {
|
||
fn to_json(&self) -> serde_json::Value;
|
||
}
|
||
|
||
impl<T: serde::Serialize> Erased for T {
|
||
fn to_json(&self) -> serde_json::Value {
|
||
serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
|
||
}
|
||
}
|
||
}
|