The basics.

This commit is contained in:
jess 2026-05-18 15:27:28 -07:00
parent d67dc1c5e8
commit bee1dd892a
10 changed files with 1651 additions and 839 deletions

948
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -14,8 +14,24 @@ path = "src/main.rs"
[dependencies]
graphite-editor = { workspace = true }
graph-craft = { workspace = true }
wgpu-executor = { workspace = true }
iced = { version = "0.14", default-features = false, features = ["wgpu", "tokio"] }
iced_wgpu = { git = "https://github.com/iced-rs/iced", branch = "master", features = ["image"] }
iced_runtime = { git = "https://github.com/iced-rs/iced", branch = "master" }
iced_widget = { git = "https://github.com/iced-rs/iced", branch = "master", features = ["wgpu", "lazy", "image"] }
iced_graphics = { git = "https://github.com/iced-rs/iced", branch = "master" }
winit = { workspace = true, features = ["wayland-csd-adwaita-notitlebar", "serde"] }
wgpu = { workspace = true }
raw-window-handle = "0.6"
pollster = "0.4"
image = { workspace = true }
include_dir = { workspace = true }
rand = { workspace = true, features = ["thread_rng"] }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

487
frontend/iced/src/app.rs Normal file
View File

@ -0,0 +1,487 @@
use graphite_editor::application::{Editor, Environment, Host, Platform};
use graphite_editor::messages::input_mapper::utility_types::input_keyboard::{Key, ModifierKeys};
use graphite_editor::messages::layout::utility_types::widget_prelude::{LayoutTarget, WidgetId};
use graphite_editor::messages::portfolio::utility_types::{FontCatalog, FontCatalogFamily, FontCatalogStyle};
use graphite_editor::messages::prelude::*;
use include_dir::{Dir, include_dir};
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread;
use iced_widget::core::{Alignment, Element as CoreElement, Length, Theme};
use iced_widget::shader::Shader;
use iced_widget::{column, container, opaque, row, scrollable, stack, text};
use rand::Rng;
use std::sync::Arc;
use crate::layout::LayoutStore;
use crate::viewport_widget::ViewportProgram;
use crate::widgets;
static DEMO_ARTWORK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../demo-artwork");
pub type Element<'a, Message> = CoreElement<'a, Message, Theme, iced_wgpu::Renderer>;
#[derive(Debug, Clone)]
pub enum Message {
Init,
WidgetClicked { layout_target: LayoutTarget, widget_id: WidgetId, value: serde_json::Value },
KeyDown { key: Key, modifiers: ModifierKeys, repeat: bool },
KeyUp { key: Key, modifiers: ModifierKeys },
}
pub struct App {
editor: Editor,
store: LayoutStore,
frontend_log: Vec<String>,
dialog: Option<DialogHeader>,
artwork_texture: Option<Arc<wgpu::Texture>>,
async_results: Receiver<AsyncResult>,
async_sender: Sender<AsyncResult>,
}
pub enum AsyncResult {
FontCatalog(FontCatalog),
FontData { font_family: String, font_style: String, data: Vec<u8> },
}
struct DialogHeader {
title: String,
icon: String,
}
impl App {
pub fn new() -> (Self, Vec<Message>) {
let environment = Environment {
platform: Platform::Desktop,
host: detect_host(),
};
let seed = rand::rng().random();
let editor = Editor::new(environment, seed);
let (async_sender, async_results) = channel();
(
Self {
editor,
store: LayoutStore::default(),
frontend_log: Vec::new(),
dialog: None,
artwork_texture: None,
async_results,
async_sender,
},
vec![Message::Init],
)
}
pub fn drain_async_results(&mut self) {
while let Ok(result) = self.async_results.try_recv() {
match result {
AsyncResult::FontCatalog(catalog) => {
tracing::info!(families = catalog.0.len(), "FontCatalog received, dispatching to editor");
let responses = self.editor.handle_message(PortfolioMessage::FontCatalogLoaded { catalog });
self.absorb(responses);
}
AsyncResult::FontData { font_family, font_style, data } => {
tracing::info!(family = %font_family, style = %font_style, bytes = data.len(), "FontData received, dispatching to editor");
let responses = self.editor.handle_message(PortfolioMessage::FontLoaded { font_family, font_style, data });
self.absorb(responses);
}
}
}
}
pub fn set_artwork_texture(&mut self, texture: Option<Arc<wgpu::Texture>>) {
self.artwork_texture = texture;
}
pub fn set_viewport_bounds(&mut self, x: f64, y: f64, width: f64, height: f64, scale: f64) {
let responses = self.editor.handle_message(ViewportMessage::Update { x, y, width, height, scale });
self.absorb(responses);
}
fn layout_or_empty(&self, target: LayoutTarget) -> Element<'_, Message> {
match self.store.get(target) {
Some(layout) => widgets::render_layout(target, layout),
None => text("").into(),
}
}
pub fn update(&mut self, message: Message) {
match message {
Message::Init => {
let responses = self.editor.handle_message(PortfolioMessage::Init);
self.absorb(responses);
}
Message::WidgetClicked { layout_target, widget_id, value } => {
let commit = self.editor.handle_message(LayoutMessage::WidgetValueCommit { layout_target, widget_id, value: value.clone() });
self.absorb(commit);
let update = self.editor.handle_message(LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value });
self.absorb(update);
let end = self.editor.handle_message(DocumentMessage::EndTransaction);
self.absorb(end);
}
Message::KeyDown { key, modifiers, repeat } => {
let responses = self.editor.handle_message(InputPreprocessorMessage::KeyDown {
key,
key_repeat: repeat,
modifier_keys: modifiers,
});
self.absorb(responses);
}
Message::KeyUp { key, modifiers } => {
let responses = self.editor.handle_message(InputPreprocessorMessage::KeyUp {
key,
key_repeat: false,
modifier_keys: modifiers,
});
self.absorb(responses);
}
}
}
fn absorb(&mut self, responses: Vec<FrontendMessage>) {
for message in responses {
let kind = message.to_discriminant().local_name();
match message {
FrontendMessage::UpdateLayout { layout_target, diff } => {
tracing::info!(?layout_target, count = diff.len(), "UpdateLayout");
self.frontend_log.push(format!("UpdateLayout → {layout_target:?} ({} diffs)", diff.len()));
for d in diff {
self.store.apply(layout_target, d);
}
if matches!(layout_target, LayoutTarget::DialogColumn1) {
if let Some(layout) = self.store.get(LayoutTarget::DialogColumn1) {
let mut buf = String::new();
collect_text(layout, &mut buf);
if !buf.is_empty() {
tracing::info!(text = %buf, "DialogColumn1 text");
}
}
}
}
FrontendMessage::DisplayDialog { title, icon } => {
tracing::info!(%title, "DisplayDialog");
self.frontend_log.push(format!("DisplayDialog → {title}"));
self.dialog = Some(DialogHeader { title, icon });
}
FrontendMessage::DialogClose => {
tracing::info!("DialogClose");
self.frontend_log.push(kind.to_string());
self.dialog = None;
}
FrontendMessage::TriggerFetchAndOpenDocument { name, filename } => {
tracing::info!(%filename, "TriggerFetchAndOpenDocument");
self.frontend_log.push(format!("TriggerFetchAndOpenDocument → {filename}"));
match DEMO_ARTWORK.get_file(&filename) {
Some(file) => {
tracing::info!(bytes = file.contents().len(), "demo bundle hit, opening");
let content = file.contents().to_vec();
let path = PathBuf::from(&filename);
let _ = name;
let opened = self.editor.handle_message(PortfolioMessage::OpenFile { path, content });
self.absorb(opened);
}
None => {
tracing::warn!(%filename, "bundled demo artwork missing");
self.frontend_log.push(format!("bundled demo artwork '{filename}' missing"));
}
}
}
FrontendMessage::TriggerFontCatalogLoad => {
tracing::info!("TriggerFontCatalogLoad → spawning fetch");
self.frontend_log.push(String::from("TriggerFontCatalogLoad → fetching"));
spawn_font_catalog_fetch(self.async_sender.clone());
}
FrontendMessage::TriggerFontDataLoad { font, url } => {
tracing::info!(family = %font.font_family, style = %font.font_style, %url, "TriggerFontDataLoad → spawning fetch");
self.frontend_log.push(format!("TriggerFontDataLoad → {} {}", font.font_family, font.font_style));
spawn_font_data_fetch(self.async_sender.clone(), font.font_family, font.font_style, url);
}
_ => self.frontend_log.push(kind.to_string()),
}
}
}
pub fn view(&self) -> Element<'_, Message> {
let menu_bar: Element<'_, Message> = self.layout_or_empty(LayoutTarget::MenuBar);
let tool_shelf = self.layout_or_empty(LayoutTarget::ToolShelf);
let working_colors = self.layout_or_empty(LayoutTarget::WorkingColors);
let tool_options = self.layout_or_empty(LayoutTarget::ToolOptions);
let document_bar = self.layout_or_empty(LayoutTarget::DocumentBar);
let node_graph_control = self.layout_or_empty(LayoutTarget::NodeGraphControlBar);
let layers_control_left = self.layout_or_empty(LayoutTarget::LayersPanelControlLeftBar);
let layers_control_right = self.layout_or_empty(LayoutTarget::LayersPanelControlRightBar);
let layers_bottom = self.layout_or_empty(LayoutTarget::LayersPanelBottomBar);
let status_hints = self.layout_or_empty(LayoutTarget::StatusBarHints);
let status_info = self.layout_or_empty(LayoutTarget::StatusBarInfo);
let welcome = self.layout_or_empty(LayoutTarget::WelcomeScreenButtons);
let menu_bar = container(menu_bar).padding(4).width(Length::Fill);
let left_strip = container(column![container(tool_shelf).padding(4), container(working_colors).padding(4)].spacing(0))
.width(Length::Fixed(48.0))
.height(Length::Fill);
let canvas_top = container(row![tool_options, document_bar].spacing(8).align_y(Alignment::Center))
.padding(4)
.width(Length::Fill);
let canvas_center: Element<'_, Message> = if self.artwork_texture.is_some() {
let viewport_program = ViewportProgram { texture: self.artwork_texture.clone() };
Shader::new(viewport_program).width(Length::Fill).height(Length::Fill).into()
} else {
container(welcome).center_x(Length::Fill).center_y(Length::Fill).width(Length::Fill).height(Length::Fill).into()
};
let center_column = column![canvas_top, canvas_center].spacing(0).width(Length::Fill).height(Length::Fill);
let right_panel = container(
column![
container(node_graph_control).padding(4).width(Length::Fill),
container(row![layers_control_left, layers_control_right].spacing(8)).padding(4).width(Length::Fill),
container(scrollable(column![].spacing(0))).padding(4).width(Length::Fill).height(Length::Fill),
container(layers_bottom).padding(4).width(Length::Fill),
]
.spacing(0),
)
.width(Length::Fixed(320.0))
.height(Length::Fill);
let body_row = row![left_strip, center_column, right_panel].spacing(0).width(Length::Fill).height(Length::Fill);
let status_bar = container(row![status_hints, container(text("")).width(Length::Fill), status_info].spacing(8).align_y(Alignment::Center))
.padding(4)
.width(Length::Fill);
let main: Element<'_, Message> = container(column![menu_bar, body_row, status_bar].spacing(0)).width(Length::Fill).height(Length::Fill).into();
let Some(header) = &self.dialog else {
return main;
};
let icon = if header.icon.is_empty() { String::new() } else { format!("[{}] ", header.icon) };
let title_text = text(format!("{icon}{}", header.title)).size(15);
let title_bar = container(title_text)
.padding([10, 16])
.width(Length::Fill)
.style(dialog_title_bar_style);
let mut body_column = column![].spacing(12);
for target in [LayoutTarget::DialogColumn1, LayoutTarget::DialogColumn2] {
if let Some(layout) = self.store.get(target) {
body_column = body_column.push(widgets::render_layout(target, layout));
}
}
let body = container(body_column).padding(20).width(Length::Fill);
let footer: Element<'_, Message> = if let Some(layout) = self.store.get(LayoutTarget::DialogButtons) {
container(row![container(text("")).width(Length::Fill), widgets::render_layout(LayoutTarget::DialogButtons, layout)].spacing(8).align_y(Alignment::Center))
.padding([10, 16])
.width(Length::Fill)
.style(dialog_footer_style)
.into()
} else {
text("").into()
};
let card_column = column![title_bar, body, footer].spacing(0);
let card: Element<'_, Message> = container(card_column).style(dialog_card_style).width(Length::FillPortion(14)).into();
let positioned = column![
iced_widget::Space::new().height(Length::FillPortion(2)),
row![
iced_widget::Space::new().width(Length::FillPortion(3)),
card,
iced_widget::Space::new().width(Length::FillPortion(3)),
]
.width(Length::Fill),
iced_widget::Space::new().height(Length::FillPortion(3)),
];
let scrim = container(positioned).width(Length::Fill).height(Length::Fill).style(dialog_scrim_style);
stack![main, opaque(scrim)].into()
}
}
fn collect_text(layout: &graphite_editor::messages::layout::utility_types::widget_prelude::Layout, buf: &mut String) {
use graphite_editor::messages::layout::utility_types::widget_prelude::*;
for group in &layout.0 {
collect_text_group(group, buf);
}
}
fn collect_text_group(group: &graphite_editor::messages::layout::utility_types::widget_prelude::LayoutGroup, buf: &mut String) {
use graphite_editor::messages::layout::utility_types::widget_prelude::*;
match group {
LayoutGroup::Column(WidgetColumn { widgets }) | LayoutGroup::Row(WidgetRow { widgets }) => {
for instance in widgets {
collect_text_widget(&instance.widget, buf);
}
}
LayoutGroup::Section(WidgetSection { layout, .. }) => collect_text(layout, buf),
LayoutGroup::Table(WidgetTable { rows, .. }) => {
for row in rows {
for instance in row {
collect_text_widget(&instance.widget, buf);
}
}
}
}
}
fn collect_text_widget(widget: &graphite_editor::messages::layout::utility_types::widget_prelude::Widget, buf: &mut String) {
use graphite_editor::messages::layout::utility_types::widget_prelude::*;
match widget {
Widget::TextLabel(w) => {
if !buf.is_empty() {
buf.push_str(" | ");
}
buf.push_str(&w.value);
}
Widget::TextInput(w) => {
if !buf.is_empty() {
buf.push_str(" | ");
}
buf.push_str(&w.value);
}
Widget::TextAreaInput(w) => {
if !buf.is_empty() {
buf.push_str(" | ");
}
buf.push_str(&w.value);
}
_ => {}
}
}
const FONT_LIST_API: &str = "https://api.graphite.art/font-list";
#[derive(serde::Deserialize)]
struct FontListResponse {
items: Vec<FontListEntry>,
}
#[derive(serde::Deserialize)]
struct FontListEntry {
family: String,
variants: Vec<String>,
files: std::collections::HashMap<String, String>,
}
fn spawn_font_catalog_fetch(sender: Sender<AsyncResult>) {
thread::spawn(move || {
let response = match reqwest::blocking::get(FONT_LIST_API) {
Ok(r) => r,
Err(e) => {
tracing::warn!("font catalog fetch failed: {e}");
return;
}
};
let parsed: FontListResponse = match response.json() {
Ok(p) => p,
Err(e) => {
tracing::warn!("font catalog parse failed: {e}");
return;
}
};
let catalog = FontCatalog(
parsed
.items
.into_iter()
.map(|entry| {
let styles = entry
.variants
.iter()
.filter_map(|variant| {
let weight = if variant == "regular" || variant == "italic" {
400
} else {
variant.trim_end_matches("italic").parse::<u32>().ok()?
};
let italic = variant.ends_with("italic");
let url = entry.files.get(variant)?.replace("http://", "https://");
Some(FontCatalogStyle { weight, italic, url })
})
.collect();
FontCatalogFamily { name: entry.family, styles }
})
.collect(),
);
let _ = sender.send(AsyncResult::FontCatalog(catalog));
});
}
fn spawn_font_data_fetch(sender: Sender<AsyncResult>, font_family: String, font_style: String, url: String) {
thread::spawn(move || {
let response = match reqwest::blocking::get(&url) {
Ok(r) => r,
Err(e) => {
tracing::warn!("font data fetch for {font_family} {font_style} failed: {e}");
return;
}
};
let bytes = match response.bytes() {
Ok(b) => b.to_vec(),
Err(e) => {
tracing::warn!("font data read for {font_family} {font_style} failed: {e}");
return;
}
};
let _ = sender.send(AsyncResult::FontData { font_family, font_style, data: bytes });
});
}
fn dialog_scrim_style(_: &Theme) -> iced_widget::container::Style {
use iced_widget::core::{Background, Color};
iced_widget::container::Style {
background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.55))),
..Default::default()
}
}
fn dialog_card_style(_: &Theme) -> iced_widget::container::Style {
use iced_widget::core::{Background, Border, Color};
iced_widget::container::Style {
background: Some(Background::Color(Color::from_rgb(0.13, 0.13, 0.13))),
border: Border {
color: Color::from_rgb(0.28, 0.28, 0.28),
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
}
}
fn dialog_title_bar_style(_: &Theme) -> iced_widget::container::Style {
use iced_widget::core::{Background, Color};
iced_widget::container::Style {
background: Some(Background::Color(Color::from_rgb(0.18, 0.18, 0.18))),
..Default::default()
}
}
fn dialog_footer_style(_: &Theme) -> iced_widget::container::Style {
use iced_widget::core::{Background, Color};
iced_widget::container::Style {
background: Some(Background::Color(Color::from_rgb(0.10, 0.10, 0.10))),
..Default::default()
}
}
#[allow(dead_code)]
fn is_dialog_target(target: LayoutTarget) -> bool {
matches!(target, LayoutTarget::DialogColumn1 | LayoutTarget::DialogColumn2 | LayoutTarget::DialogButtons)
}
fn detect_host() -> Host {
if cfg!(target_os = "macos") {
Host::Mac
} else if cfg!(target_os = "windows") {
Host::Windows
} else {
Host::Linux
}
}

144
frontend/iced/src/input.rs Normal file
View File

@ -0,0 +1,144 @@
use graphite_editor::messages::input_mapper::utility_types::input_keyboard::{Key, ModifierKeys};
use winit::keyboard::{KeyCode, ModifiersState, PhysicalKey};
pub fn translate_physical_winit(physical: PhysicalKey) -> Key {
let PhysicalKey::Code(code) = physical else {
return Key::Unidentified;
};
translate_code(code)
}
pub fn translate_modifiers_winit(m: ModifiersState) -> ModifierKeys {
let mut out = ModifierKeys::empty();
if m.shift_key() {
out |= ModifierKeys::SHIFT;
}
if m.alt_key() {
out |= ModifierKeys::ALT;
}
if m.control_key() {
out |= ModifierKeys::CONTROL;
}
if m.meta_key() {
out |= ModifierKeys::META_OR_COMMAND;
}
out
}
fn translate_code(code: KeyCode) -> Key {
match code {
KeyCode::Digit0 | KeyCode::Numpad0 => Key::Digit0,
KeyCode::Digit1 | KeyCode::Numpad1 => Key::Digit1,
KeyCode::Digit2 | KeyCode::Numpad2 => Key::Digit2,
KeyCode::Digit3 | KeyCode::Numpad3 => Key::Digit3,
KeyCode::Digit4 | KeyCode::Numpad4 => Key::Digit4,
KeyCode::Digit5 | KeyCode::Numpad5 => Key::Digit5,
KeyCode::Digit6 | KeyCode::Numpad6 => Key::Digit6,
KeyCode::Digit7 | KeyCode::Numpad7 => Key::Digit7,
KeyCode::Digit8 | KeyCode::Numpad8 => Key::Digit8,
KeyCode::Digit9 | KeyCode::Numpad9 => Key::Digit9,
KeyCode::KeyA => Key::KeyA,
KeyCode::KeyB => Key::KeyB,
KeyCode::KeyC => Key::KeyC,
KeyCode::KeyD => Key::KeyD,
KeyCode::KeyE => Key::KeyE,
KeyCode::KeyF => Key::KeyF,
KeyCode::KeyG => Key::KeyG,
KeyCode::KeyH => Key::KeyH,
KeyCode::KeyI => Key::KeyI,
KeyCode::KeyJ => Key::KeyJ,
KeyCode::KeyK => Key::KeyK,
KeyCode::KeyL => Key::KeyL,
KeyCode::KeyM => Key::KeyM,
KeyCode::KeyN => Key::KeyN,
KeyCode::KeyO => Key::KeyO,
KeyCode::KeyP => Key::KeyP,
KeyCode::KeyQ => Key::KeyQ,
KeyCode::KeyR => Key::KeyR,
KeyCode::KeyS => Key::KeyS,
KeyCode::KeyT => Key::KeyT,
KeyCode::KeyU => Key::KeyU,
KeyCode::KeyV => Key::KeyV,
KeyCode::KeyW => Key::KeyW,
KeyCode::KeyX => Key::KeyX,
KeyCode::KeyY => Key::KeyY,
KeyCode::KeyZ => Key::KeyZ,
KeyCode::Backquote => Key::Backquote,
KeyCode::Backslash => Key::Backslash,
KeyCode::BracketLeft => Key::BracketLeft,
KeyCode::BracketRight => Key::BracketRight,
KeyCode::Comma | KeyCode::NumpadComma => Key::Comma,
KeyCode::Equal | KeyCode::NumpadEqual => Key::Equal,
KeyCode::Minus | KeyCode::NumpadSubtract => Key::Minus,
KeyCode::Period | KeyCode::NumpadDecimal => Key::Period,
KeyCode::Quote => Key::Quote,
KeyCode::Semicolon => Key::Semicolon,
KeyCode::Slash | KeyCode::NumpadDivide => Key::Slash,
KeyCode::AltLeft | KeyCode::AltRight => Key::Alt,
KeyCode::MetaLeft | KeyCode::MetaRight => Key::Meta,
KeyCode::ShiftLeft | KeyCode::ShiftRight => Key::Shift,
KeyCode::ControlLeft | KeyCode::ControlRight => Key::Control,
KeyCode::Backspace => Key::Backspace,
KeyCode::CapsLock => Key::CapsLock,
KeyCode::ContextMenu => Key::ContextMenu,
KeyCode::Enter | KeyCode::NumpadEnter => Key::Enter,
KeyCode::Space => Key::Space,
KeyCode::Tab => Key::Tab,
KeyCode::Delete => Key::Delete,
KeyCode::End => Key::End,
KeyCode::Help => Key::Help,
KeyCode::Home => Key::Home,
KeyCode::Insert => Key::Insert,
KeyCode::PageDown => Key::PageDown,
KeyCode::PageUp => Key::PageUp,
KeyCode::ArrowDown => Key::ArrowDown,
KeyCode::ArrowLeft => Key::ArrowLeft,
KeyCode::ArrowRight => Key::ArrowRight,
KeyCode::ArrowUp => Key::ArrowUp,
KeyCode::NumLock => Key::NumLock,
KeyCode::NumpadAdd => Key::NumpadAdd,
KeyCode::NumpadHash => Key::NumpadHash,
KeyCode::NumpadMultiply | KeyCode::NumpadStar => Key::NumpadMultiply,
KeyCode::NumpadParenLeft => Key::NumpadParenLeft,
KeyCode::NumpadParenRight => Key::NumpadParenRight,
KeyCode::Escape => Key::Escape,
KeyCode::F1 => Key::F1,
KeyCode::F2 => Key::F2,
KeyCode::F3 => Key::F3,
KeyCode::F4 => Key::F4,
KeyCode::F5 => Key::F5,
KeyCode::F6 => Key::F6,
KeyCode::F7 => Key::F7,
KeyCode::F8 => Key::F8,
KeyCode::F9 => Key::F9,
KeyCode::F10 => Key::F10,
KeyCode::F11 => Key::F11,
KeyCode::F12 => Key::F12,
KeyCode::F13 => Key::F13,
KeyCode::F14 => Key::F14,
KeyCode::F15 => Key::F15,
KeyCode::F16 => Key::F16,
KeyCode::F17 => Key::F17,
KeyCode::F18 => Key::F18,
KeyCode::F19 => Key::F19,
KeyCode::F20 => Key::F20,
KeyCode::F21 => Key::F21,
KeyCode::F22 => Key::F22,
KeyCode::F23 => Key::F23,
KeyCode::F24 => Key::F24,
KeyCode::Fn => Key::Fn,
KeyCode::FnLock => Key::FnLock,
KeyCode::PrintScreen => Key::PrintScreen,
KeyCode::ScrollLock => Key::ScrollLock,
KeyCode::Pause => Key::Pause,
_ => Key::Unidentified,
}
}

View File

@ -14,6 +14,10 @@ impl LayoutStore {
apply_diff_layout(layout, &diff.widget_path, diff.new_value);
}
pub fn get(&self, target: LayoutTarget) -> Option<&Layout> {
self.layouts.get(&target)
}
pub fn iter(&self) -> impl Iterator<Item = (&LayoutTarget, &Layout)> {
self.layouts.iter()
}

View File

@ -1,109 +1,13 @@
mod app;
mod input;
mod layout;
mod shell;
mod viewport;
mod viewport_widget;
mod widgets;
use graphite_editor::application::{Editor, Environment, Host, Platform};
use graphite_editor::messages::prelude::*;
use iced::widget::{column, container, scrollable, text};
use iced::{Element, Length, Task, Theme};
use rand::Rng;
use crate::layout::LayoutStore;
#[derive(Debug, Clone)]
pub enum Message {
Init,
WidgetActivated,
}
struct App {
editor: Editor,
store: LayoutStore,
frontend_log: Vec<String>,
}
impl App {
fn boot() -> (Self, Task<Message>) {
let environment = Environment {
platform: Platform::Desktop,
host: detect_host(),
};
let seed = rand::rng().random();
let editor = Editor::new(environment, seed);
(
Self {
editor,
store: LayoutStore::default(),
frontend_log: Vec::new(),
},
Task::done(Message::Init),
)
}
fn title(&self) -> String {
String::from("Graphite")
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Init => {
let responses = self.editor.handle_message(PortfolioMessage::Init);
self.absorb(responses);
}
Message::WidgetActivated => {}
}
Task::none()
}
fn absorb(&mut self, responses: Vec<FrontendMessage>) {
for message in responses {
let kind = message.to_discriminant().local_name();
match message {
FrontendMessage::UpdateLayout { layout_target, diff } => {
self.frontend_log.push(format!("UpdateLayout → {layout_target:?} ({} diffs)", diff.len()));
for d in diff {
self.store.apply(layout_target, d);
}
}
_ => self.frontend_log.push(kind.to_string()),
}
}
}
fn view(&self) -> Element<'_, Message> {
let mut targets = column![].spacing(12);
let mut entries: Vec<_> = self.store.iter().collect();
entries.sort_by_key(|(target, _)| **target as u8);
for (target, layout) in entries {
let header = text(format!("{target:?}")).size(14);
let body = widgets::render_layout(layout);
targets = targets.push(column![header, container(body).padding(6)].spacing(4));
}
let log_body = self.frontend_log.iter().fold(column![].spacing(2), |c, line| c.push(text(line.as_str()).size(11)));
let log_pane = column![text("frontend messages").size(14), container(log_body).padding(6)].spacing(4);
container(scrollable(column![targets, log_pane].spacing(16))).padding(16).width(Length::Fill).height(Length::Fill).into()
}
fn theme(&self) -> Theme {
Theme::Dark
}
}
fn detect_host() -> Host {
if cfg!(target_os = "macos") {
Host::Mac
} else if cfg!(target_os = "windows") {
Host::Windows
} else {
Host::Linux
}
}
fn main() -> iced::Result {
fn main() {
tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())).init();
iced::application(App::boot, App::update, App::view).title(App::title).theme(App::theme).run()
shell::run();
}

131
frontend/iced/src/shell.rs Normal file
View File

@ -0,0 +1,131 @@
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use winit::application::ApplicationHandler;
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
use winit::event::{ButtonSource, ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::ModifiersState;
use winit::window::{Window, WindowAttributes, WindowId};
use crate::viewport::ViewportHandle;
const DEFAULT_LOGICAL: (u32, u32) = (1280, 800);
const MIN_LOGICAL: (u32, u32) = (640, 480);
pub fn run() {
let event_loop = EventLoop::new().expect("winit: create event loop");
event_loop.set_control_flow(ControlFlow::Wait);
let mut app = ShellApp::default();
if let Err(e) = event_loop.run_app(&mut app) {
eprintln!("graphite-iced shell exited with error: {e}");
std::process::exit(1);
}
}
#[derive(Default)]
struct ShellApp {
window: Option<Box<dyn Window>>,
handle: Option<ViewportHandle>,
modifiers: ModifiersState,
last_cursor: PhysicalPosition<f64>,
}
impl ApplicationHandler for ShellApp {
fn can_create_surfaces(&mut self, event_loop: &dyn ActiveEventLoop) {
if self.window.is_some() {
return;
}
let attrs = WindowAttributes::default()
.with_title("Graphite")
.with_surface_size(LogicalSize::new(DEFAULT_LOGICAL.0, DEFAULT_LOGICAL.1))
.with_min_surface_size(LogicalSize::new(MIN_LOGICAL.0, MIN_LOGICAL.1));
let window = event_loop.create_window(attrs).expect("winit: create window");
let inner: PhysicalSize<u32> = window.surface_size();
let scale = window.scale_factor() as f32;
let raw_window = window.window_handle().expect("winit: window handle").as_raw();
let raw_display = window.display_handle().expect("winit: display handle").as_raw();
let handle = ViewportHandle::new_from_raw(raw_window, raw_display, (inner.width as f32 / scale).max(1.0), (inner.height as f32 / scale).max(1.0), scale).expect("graphite-iced: failed to build wgpu+iced viewport");
self.window = Some(window);
self.handle = Some(handle);
}
fn window_event(&mut self, event_loop: &dyn ActiveEventLoop, _id: WindowId, event: WindowEvent) {
let Some(window) = self.window.as_ref() else {
return;
};
let Some(handle) = self.handle.as_mut() else {
return;
};
let scale = window.scale_factor() as f32;
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::SurfaceResized(PhysicalSize { width, height }) => {
let w = (width as f32 / scale).max(1.0);
let h = (height as f32 / scale).max(1.0);
handle.resize_px(w, h, scale);
window.request_redraw();
}
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
let size = window.surface_size();
let s = scale_factor as f32;
let w = (size.width as f32 / s).max(1.0);
let h = (size.height as f32 / s).max(1.0);
handle.resize_px(w, h, s);
window.request_redraw();
}
WindowEvent::PointerMoved { position, .. } => {
self.last_cursor = position;
handle.push_mouse_move(position.x as f32 / scale, position.y as f32 / scale);
window.request_redraw();
}
WindowEvent::PointerLeft { .. } => {
handle.push_mouse_left();
window.request_redraw();
}
WindowEvent::PointerButton { state, button, position, .. } => {
let mouse_button = match button {
ButtonSource::Mouse(m) => m,
_ => return,
};
let code = match mouse_button {
MouseButton::Left => 0,
MouseButton::Right => 1,
MouseButton::Middle => 2,
_ => return,
};
let pressed = matches!(state, ElementState::Pressed);
handle.push_mouse_button(position.x as f32 / scale, position.y as f32 / scale, code, pressed);
window.request_redraw();
}
WindowEvent::MouseWheel { delta, .. } => {
let (dx, dy) = match delta {
MouseScrollDelta::LineDelta(x, y) => (x * 20.0, y * 20.0),
MouseScrollDelta::PixelDelta(p) => (p.x as f32, p.y as f32),
};
handle.push_mouse_scroll(self.last_cursor.x as f32 / scale, self.last_cursor.y as f32 / scale, dx, dy);
window.request_redraw();
}
WindowEvent::ModifiersChanged(mods) => {
self.modifiers = mods.state();
}
WindowEvent::KeyboardInput { event, .. } => {
let KeyEvent {
physical_key, logical_key, state, text, repeat, ..
} = event;
let pressed = matches!(state, ElementState::Pressed);
let utf8 = text.as_ref().map(|s| s.to_string()).or_else(|| match &logical_key {
winit::keyboard::Key::Character(s) => Some(s.to_string()),
_ => None,
});
handle.push_key_event(physical_key, logical_key, utf8, self.modifiers, pressed, repeat);
window.request_redraw();
}
WindowEvent::RedrawRequested => handle.render_frame(),
_ => {}
}
}
}

View File

@ -0,0 +1,284 @@
use graph_craft::application_io::PlatformApplicationIo;
use graphite_editor::node_graph_executor;
use iced_graphics::{Shell as GShell, Viewport};
use iced_runtime::user_interface::{self, UserInterface};
use iced_wgpu::Engine;
use iced_wgpu::core::renderer::{self, Style};
use iced_wgpu::core::time::Instant;
use iced_wgpu::core::{Color, Event, Point, Size, Theme, mouse, window};
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
use std::sync::Arc;
use winit::keyboard::{Key as WKey, ModifiersState, PhysicalKey};
use crate::app::{App, Message};
use crate::input;
pub struct ViewportHandle {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
#[allow(dead_code)]
queue: wgpu::Queue,
#[allow(dead_code)]
wgpu_context: wgpu_executor::WgpuContext,
format: wgpu::TextureFormat,
scale: f32,
renderer: iced_wgpu::Renderer,
viewport: Viewport,
cache: user_interface::Cache,
events: Vec<Event>,
cursor: mouse::Cursor,
needs_redraw: bool,
state: App,
artwork_texture: Option<Arc<wgpu::Texture>>,
}
impl ViewportHandle {
pub fn new_from_raw(raw_window: RawWindowHandle, raw_display: RawDisplayHandle, width: f32, height: f32, scale: f32) -> Option<Self> {
let backends = preferred_backends();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends,
..wgpu::InstanceDescriptor::new_without_display_handle()
});
let surface = {
let target = wgpu::SurfaceTargetUnsafe::RawHandle {
raw_display_handle: Some(raw_display),
raw_window_handle: raw_window,
};
unsafe { instance.create_surface_unsafe(target).ok()? }
};
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
}))
.ok()?;
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
let phys_w = ((width * scale) as u32).max(1);
let phys_h = ((height * scale) as u32).max(1);
let caps = surface.get_capabilities(&adapter);
let format = caps.formats.iter().find(|f| f.is_srgb()).copied().unwrap_or(*caps.formats.first()?);
let alpha_mode = caps.alpha_modes.first().copied().unwrap_or(wgpu::CompositeAlphaMode::Auto);
surface.configure(
&device,
&wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width: phys_w,
height: phys_h,
present_mode: wgpu::PresentMode::AutoVsync,
alpha_mode,
view_formats: vec![],
desired_maximum_frame_latency: 2,
},
);
let wgpu_context = wgpu_executor::WgpuContext {
device: Arc::new(device.clone()),
queue: Arc::new(queue.clone()),
instance: Arc::new(instance),
adapter: Arc::new(adapter.clone()),
};
let engine = Engine::new(&adapter, device.clone(), queue.clone(), format, None, GShell::headless());
let renderer = iced_wgpu::Renderer::new(engine, renderer::Settings::default());
let viewport = Viewport::with_physical_size(Size::new(phys_w, phys_h), scale);
let (state, initial) = App::new();
let application_io = PlatformApplicationIo::new_with_context(wgpu_context.clone());
pollster::block_on(node_graph_executor::replace_application_io(application_io));
let mut handle = Self {
surface,
device,
queue,
wgpu_context,
format,
scale,
renderer,
viewport,
cache: user_interface::Cache::new(),
events: Vec::new(),
cursor: mouse::Cursor::Available(Point::new(width / 2.0, height / 2.0)),
needs_redraw: true,
state,
artwork_texture: None,
};
for msg in initial {
handle.state.update(msg);
}
handle.push_viewport_bounds(width, height);
Some(handle)
}
fn push_viewport_bounds(&mut self, logical_width: f32, logical_height: f32) {
const SIDEBAR_LOGICAL_PX: f32 = 340.0;
let viewport_width = (logical_width - SIDEBAR_LOGICAL_PX).max(1.0);
let viewport_height = logical_height.max(1.0);
self.state
.set_viewport_bounds(0.0, 0.0, viewport_width as f64, viewport_height as f64, self.scale as f64);
}
fn poll_node_graph(&mut self) {
let (has_run, image_texture) = pollster::block_on(node_graph_executor::run_node_graph());
if has_run {
let dims = image_texture.as_ref().map(|t| {
let tex: &wgpu::Texture = t.as_ref();
(tex.width(), tex.height())
});
tracing::info!(?dims, "node graph ran");
}
if let Some(image_texture) = image_texture {
let texture: Arc<wgpu::Texture> = image_texture.into();
self.artwork_texture = Some(texture.clone());
self.state.set_artwork_texture(Some(texture));
}
}
pub fn resize_px(&mut self, width: f32, height: f32, scale: f32) {
let phys_w = ((width * scale) as u32).max(1);
let phys_h = ((height * scale) as u32).max(1);
self.scale = scale;
self.viewport = Viewport::with_physical_size(Size::new(phys_w, phys_h), scale);
self.surface.configure(
&self.device,
&wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: self.format,
width: phys_w,
height: phys_h,
present_mode: wgpu::PresentMode::AutoVsync,
alpha_mode: wgpu::CompositeAlphaMode::Auto,
view_formats: vec![],
desired_maximum_frame_latency: 2,
},
);
self.push_viewport_bounds(width, height);
self.needs_redraw = true;
}
pub fn push_mouse_move(&mut self, x: f32, y: f32) {
let p = Point::new(x, y);
self.cursor = mouse::Cursor::Available(p);
self.events.push(Event::Mouse(mouse::Event::CursorMoved { position: p }));
self.needs_redraw = true;
}
pub fn push_mouse_left(&mut self) {
self.cursor = mouse::Cursor::Unavailable;
self.events.push(Event::Mouse(mouse::Event::CursorLeft));
self.needs_redraw = true;
}
pub fn push_mouse_button(&mut self, x: f32, y: f32, button: u32, pressed: bool) {
let p = Point::new(x, y);
self.cursor = mouse::Cursor::Available(p);
self.events.push(Event::Mouse(mouse::Event::CursorMoved { position: p }));
let b = match button {
0 => mouse::Button::Left,
1 => mouse::Button::Right,
2 => mouse::Button::Middle,
_ => return,
};
let ev = if pressed { mouse::Event::ButtonPressed(b) } else { mouse::Event::ButtonReleased(b) };
self.events.push(Event::Mouse(ev));
self.needs_redraw = true;
}
pub fn push_mouse_scroll(&mut self, x: f32, y: f32, dx: f32, dy: f32) {
let p = Point::new(x, y);
self.cursor = mouse::Cursor::Available(p);
self.events.push(Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: dx, y: dy },
}));
self.needs_redraw = true;
}
pub fn push_key_event(&mut self, physical: PhysicalKey, _logical: WKey, _text: Option<String>, modifiers: ModifiersState, pressed: bool, repeat: bool) {
let editor_key = input::translate_physical_winit(physical);
let editor_modifiers = input::translate_modifiers_winit(modifiers);
let msg = if pressed {
Message::KeyDown {
key: editor_key,
modifiers: editor_modifiers,
repeat,
}
} else {
Message::KeyUp {
key: editor_key,
modifiers: editor_modifiers,
}
};
self.state.update(msg);
self.needs_redraw = true;
}
pub fn render_frame(&mut self) {
if !self.needs_redraw && self.events.is_empty() {
return;
}
let frame = match self.surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(t) | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t,
wgpu::CurrentSurfaceTexture::Occluded => return,
other => {
tracing::warn!("surface acquire failed: {other:?}");
return;
}
};
let view = frame.texture.create_view(&Default::default());
let logical = self.viewport.logical_size();
self.events.push(Event::Window(window::Event::RedrawRequested(Instant::now())));
let cache = std::mem::take(&mut self.cache);
let mut ui = UserInterface::build(self.state.view(), Size::new(logical.width, logical.height), cache, &mut self.renderer);
let mut messages: Vec<Message> = Vec::new();
let drained: Vec<Event> = self.events.drain(..).collect();
let _ = ui.update(&drained, self.cursor, &mut self.renderer, &mut messages);
let theme = Theme::Dark;
let style = Style { text_color: Color::WHITE };
if messages.is_empty() {
ui.draw(&mut self.renderer, &theme, &style, self.cursor);
self.cache = ui.into_cache();
} else {
let cache = ui.into_cache();
for msg in messages.drain(..) {
self.state.update(msg);
}
let mut ui = UserInterface::build(self.state.view(), Size::new(logical.width, logical.height), cache, &mut self.renderer);
ui.draw(&mut self.renderer, &theme, &style, self.cursor);
self.cache = ui.into_cache();
}
self.state.drain_async_results();
self.poll_node_graph();
self.renderer.present(Some(Color::from_rgb(0.07, 0.07, 0.07)), self.format, &view, &self.viewport);
frame.present();
self.needs_redraw = false;
}
}
fn preferred_backends() -> wgpu::Backends {
#[cfg(target_os = "macos")]
{
wgpu::Backends::METAL
}
#[cfg(target_os = "windows")]
{
wgpu::Backends::DX12
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
wgpu::Backends::all()
}
}

View File

@ -0,0 +1,194 @@
use iced_widget::core::{Rectangle, mouse};
use iced_widget::shader::{self, Pipeline, Primitive, Program};
use std::sync::Arc;
const VIEWPORT_SHADER: &str = r#"
struct VsOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs(@builtin(vertex_index) idx: u32) -> VsOut {
var out: VsOut;
let x = f32((idx << 1u) & 2u);
let y = f32(idx & 2u);
out.uv = vec2<f32>(x, y);
out.pos = vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
return out;
}
@group(0) @binding(0) var artwork: texture_2d<f32>;
@group(0) @binding(1) var artwork_sampler: sampler;
@fragment
fn fs(in: VsOut) -> @location(0) vec4<f32> {
return textureSample(artwork, artwork_sampler, in.uv);
}
"#;
pub struct ViewportProgram {
pub texture: Option<Arc<wgpu::Texture>>,
}
impl<Message> Program<Message> for ViewportProgram {
type State = ();
type Primitive = ViewportPrimitive;
fn draw(&self, _state: &Self::State, _cursor: mouse::Cursor, _bounds: Rectangle) -> Self::Primitive {
ViewportPrimitive { texture: self.texture.clone() }
}
}
#[derive(Debug)]
pub struct ViewportPrimitive {
pub texture: Option<Arc<wgpu::Texture>>,
}
impl Primitive for ViewportPrimitive {
type Pipeline = ViewportPipeline;
fn prepare(&self, pipeline: &mut Self::Pipeline, device: &wgpu::Device, _queue: &wgpu::Queue, _bounds: &Rectangle, _viewport: &shader::Viewport) {
let Some(texture) = &self.texture else {
pipeline.bind_group = None;
pipeline.current_texture = None;
return;
};
let id = Arc::as_ptr(texture);
if pipeline.current_texture == Some(id) {
return;
}
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("graphite-viewport-bind-group"),
layout: &pipeline.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&pipeline.sampler),
},
],
});
pipeline.bind_group = Some(bind_group);
pipeline.current_texture = Some(id);
}
fn draw(&self, pipeline: &Self::Pipeline, render_pass: &mut wgpu::RenderPass<'_>) -> bool {
let Some(bind_group) = pipeline.bind_group.as_ref() else {
return true;
};
render_pass.set_pipeline(&pipeline.render_pipeline);
render_pass.set_bind_group(0, bind_group, &[]);
render_pass.draw(0..3, 0..1);
true
}
}
pub struct ViewportPipeline {
render_pipeline: wgpu::RenderPipeline,
bind_group_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
bind_group: Option<wgpu::BindGroup>,
current_texture: Option<*const wgpu::Texture>,
}
unsafe impl Send for ViewportPipeline {}
unsafe impl Sync for ViewportPipeline {}
impl Pipeline for ViewportPipeline {
fn new(device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("graphite-viewport-shader"),
source: wgpu::ShaderSource::Wgsl(VIEWPORT_SHADER.into()),
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("graphite-viewport-bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("graphite-viewport-sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
..Default::default()
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("graphite-viewport-pipeline-layout"),
bind_group_layouts: &[Some(&bind_group_layout)],
immediate_size: 0,
});
let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("graphite-viewport-pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
});
Self {
render_pipeline,
bind_group_layout,
sampler,
bind_group: None,
current_texture: None,
}
}
}

View File

@ -1,37 +1,120 @@
use graphite_editor::messages::layout::utility_types::widget_prelude::*;
use iced::widget::{Space, button, column, container, row, text};
use iced::{Alignment, Element, Length};
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};
use crate::Message;
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 render_layout(layout: &Layout) -> Element<'_, Message> {
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(group));
col = col.push(render_group(target, group));
}
col.into()
}
fn render_group(group: &LayoutGroup) -> Element<'_, Message> {
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(&instance.widget));
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);
let mut r = row![].spacing(4).align_y(Alignment::Center).width(Length::Fill);
for instance in widgets {
r = r.push(widget_to_element(&instance.widget));
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(child));
col = col.push(render_group(target, child));
}
container(col).padding(6).into()
}
@ -40,7 +123,7 @@ fn render_group(group: &LayoutGroup) -> Element<'_, Message> {
for table_row in rows {
let mut r = row![].spacing(4);
for instance in table_row {
r = r.push(widget_to_element(&instance.widget));
r = r.push(widget_to_element(target, instance.widget_id, &instance.widget));
}
col = col.push(r);
}
@ -50,20 +133,30 @@ fn render_group(group: &LayoutGroup) -> Element<'_, Message> {
}
/// maps every Widget variant to an ICED element. labels appended for all available items, not just implemented ones.
fn widget_to_element(widget: &Widget) -> Element<'_, Message> {
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:i :Horizontal => Space::new().width(Length::Fixed(8.0)).into(),
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(Message::WidgetActivated).into(),
Widget::TextButton(w) => button(text(w.label.as_str()).size(13)).on_press(Message::WidgetActivated).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(Message::WidgetActivated).into()
button(text(format!("[{label}]"))).on_press(click(json(w))).style(graphite_button).into()
}
Widget::CheckboxInput(w) => {
let mark = if w.checked { "[x]" } else { "[ ]" };
@ -79,7 +172,7 @@ fn widget_to_element(widget: &Widget) -> Element<'_, Message> {
} else {
""
};
r = r.push(button(text(label).size(12)).on_press(Message::WidgetActivated));
r = r.push(button(text(label).size(12)).on_press(click(json(w))).style(graphite_button));
}
r.into()
}
@ -89,7 +182,7 @@ fn widget_to_element(widget: &Widget) -> Element<'_, Message> {
.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(Message::WidgetActivated).into()
button(text(format!("{current}")).size(12)).on_press(click(json(w))).style(graphite_button).into()
}
Widget::NumberInput(w) => {
let body = match w.value {
@ -116,9 +209,44 @@ fn widget_to_element(widget: &Widget) -> Element<'_, Message> {
}
r.into()
}
Widget::ParameterExposeButton(_) => button(text("").size(11)).on_press(Message::WidgetActivated).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(_) => button(text("[img]").size(11)).on_press(Message::WidgetActivated).into(),
Widget::ImageLabel(_) => text("[img]").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)
}
}
}