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>> = LazyLock::new(|| Mutex::new(HashMap::new())); fn lookup_image_handle(name: &str) -> Option { 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 { value.trim_end_matches("px").parse::().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 Erased for T { fn to_json(&self) -> serde_json::Value { serde_json::to_value(self).unwrap_or(serde_json::Value::Null) } } }