Graphite/frontend/iced/src/widgets.rs

253 lines
8.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}
}