From d67dc1c5e8420adb492d2b618215f99ee8bd5c5c Mon Sep 17 00:00:00 2001 From: jess Date: Sun, 17 May 2026 02:28:21 -0700 Subject: [PATCH 1/3] structure for ICED to fill GUI into. --- frontend/iced/src/layout.rs | 66 +++++++++++++++++++ frontend/iced/src/main.rs | 63 ++++++++++++++---- frontend/iced/src/widgets.rs | 124 +++++++++++++++++++++++++++++++++++ 3 files changed, 239 insertions(+), 14 deletions(-) create mode 100644 frontend/iced/src/layout.rs create mode 100644 frontend/iced/src/widgets.rs diff --git a/frontend/iced/src/layout.rs b/frontend/iced/src/layout.rs new file mode 100644 index 00000000..ce579e6d --- /dev/null +++ b/frontend/iced/src/layout.rs @@ -0,0 +1,66 @@ +/// applies the editor's layout diffs. each diff is comprised of a widget_path and a new_value. empty path = swap the whole layout. +/// otherwise, descend one step each index. tables descend through [row, col] indexed cells. +use graphite_editor::messages::layout::utility_types::widget_prelude::*; +use std::collections::HashMap; + +#[derive(Default)] +pub struct LayoutStore { + layouts: HashMap, +} + +impl LayoutStore { + pub fn apply(&mut self, target: LayoutTarget, diff: WidgetDiff) { + let layout = self.layouts.entry(target).or_default(); + apply_diff_layout(layout, &diff.widget_path, diff.new_value); + } + + pub fn iter(&self) -> impl Iterator { + self.layouts.iter() + } +} + +fn apply_diff_layout(layout: &mut Layout, path: &[usize], new_value: DiffUpdate) { + let Some((first, rest)) = path.split_first() else { + if let DiffUpdate::Layout(new) = new_value { + *layout = new; + } + return; + }; + if let Some(group) = layout.0.get_mut(*first) { + apply_diff_group(group, rest, new_value); + } +} + +fn apply_diff_group(group: &mut LayoutGroup, path: &[usize], new_value: DiffUpdate) { + let Some((first, rest)) = path.split_first() else { + if let DiffUpdate::LayoutGroup(new) = new_value { + *group = new; + } + return; + }; + match group { + LayoutGroup::Column(WidgetColumn { widgets }) => apply_diff_instance(widgets.get_mut(*first), rest, new_value), + LayoutGroup::Row(WidgetRow { widgets }) => apply_diff_instance(widgets.get_mut(*first), rest, new_value), + LayoutGroup::Section(WidgetSection { layout, .. }) => { + if let Some(child) = layout.0.get_mut(*first) { + apply_diff_group(child, rest, new_value); + } + } + LayoutGroup::Table(WidgetTable { rows, .. }) => { + if let Some(table_row) = rows.get_mut(*first) + && let Some((col, rest_after_col)) = rest.split_first() + { + apply_diff_instance(table_row.get_mut(*col), rest_after_col, new_value); + } + } + } +} + +fn apply_diff_instance(slot: Option<&mut WidgetInstance>, rest: &[usize], new_value: DiffUpdate) { + if !rest.is_empty() { + return; + } + if let (Some(slot), DiffUpdate::Widget(new)) = (slot, new_value) { + *slot = new; + } +} diff --git a/frontend/iced/src/main.rs b/frontend/iced/src/main.rs index 243b3fd2..1a79b338 100644 --- a/frontend/iced/src/main.rs +++ b/frontend/iced/src/main.rs @@ -1,17 +1,24 @@ +mod layout; +mod widgets; + use graphite_editor::application::{Editor, Environment, Host, Platform}; use graphite_editor::messages::prelude::*; -use iced::widget::{column, container, text}; +use iced::widget::{column, container, scrollable, text}; use iced::{Element, Length, Task, Theme}; use rand::Rng; +use crate::layout::LayoutStore; + #[derive(Debug, Clone)] -enum Message { +pub enum Message { Init, + WidgetActivated, } struct App { editor: Editor, - last_frontend_count: usize, + store: LayoutStore, + frontend_log: Vec, } impl App { @@ -23,7 +30,14 @@ impl App { let seed = rand::rng().random(); let editor = Editor::new(environment, seed); - (Self { editor, last_frontend_count: 0 }, Task::done(Message::Init)) + ( + Self { + editor, + store: LayoutStore::default(), + frontend_log: Vec::new(), + }, + Task::done(Message::Init), + ) } fn title(&self) -> String { @@ -34,22 +48,43 @@ impl App { match message { Message::Init => { let responses = self.editor.handle_message(PortfolioMessage::Init); - self.last_frontend_count = responses.len(); - tracing::info!(count = responses.len(), "editor responded to PortfolioMessage::Init"); - for response in &responses { - tracing::info!(kind = %response.to_discriminant().local_name(), "frontend message"); - } + self.absorb(responses); } + Message::WidgetActivated => {} } Task::none() } + fn absorb(&mut self, responses: Vec) { + 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> { - container(column![text("Graphite").size(40), text(format!("editor produced {} frontend messages at boot", self.last_frontend_count)).size(14),].spacing(8)) - .padding(24) - .width(Length::Fill) - .height(Length::Fill) - .into() + 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 { diff --git a/frontend/iced/src/widgets.rs b/frontend/iced/src/widgets.rs new file mode 100644 index 00000000..a5cce79f --- /dev/null +++ b/frontend/iced/src/widgets.rs @@ -0,0 +1,124 @@ +use graphite_editor::messages::layout::utility_types::widget_prelude::*; +use iced::widget::{Space, button, column, container, row, text}; +use iced::{Alignment, Element, Length}; + +use crate::Message; + +pub fn render_layout(layout: &Layout) -> Element<'_, Message> { + let mut col = column![].spacing(4); + for group in &layout.0 { + col = col.push(render_group(group)); + } + col.into() +} + +fn render_group(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.into() + } + LayoutGroup::Row(WidgetRow { widgets }) => { + let mut r = row![].spacing(4).align_y(Alignment::Center); + for instance in widgets { + r = r.push(widget_to_element(&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)); + } + 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(&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(widget: &Widget) -> Element<'_, Message> { + 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::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::PopoverButton(w) => { + let label = w.icon.as_deref().unwrap_or("▾"); + button(text(format!("[{label}]"))).on_press(Message::WidgetActivated).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(Message::WidgetActivated)); + } + 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(Message::WidgetActivated).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(_) => button(text("●").size(11)).on_press(Message::WidgetActivated).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(), + } +} -- 2.51.0 From bee1dd892aeed94eab427a3847c844ac54384355 Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 18 May 2026 15:27:28 -0700 Subject: [PATCH 2/3] The basics. --- Cargo.lock | 948 +++++++-------------------- frontend/iced/Cargo.toml | 18 +- frontend/iced/src/app.rs | 487 ++++++++++++++ frontend/iced/src/input.rs | 144 ++++ frontend/iced/src/layout.rs | 4 + frontend/iced/src/main.rs | 110 +--- frontend/iced/src/shell.rs | 131 ++++ frontend/iced/src/viewport.rs | 284 ++++++++ frontend/iced/src/viewport_widget.rs | 194 ++++++ frontend/iced/src/widgets.rs | 170 ++++- 10 files changed, 1651 insertions(+), 839 deletions(-) create mode 100644 frontend/iced/src/app.rs create mode 100644 frontend/iced/src/input.rs create mode 100644 frontend/iced/src/shell.rs create mode 100644 frontend/iced/src/viewport.rs create mode 100644 frontend/iced/src/viewport_widget.rs diff --git a/Cargo.lock b/Cargo.lock index 43f82813..b5160635 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,6 +55,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + [[package]] name = "allocator-api2" version = "0.2.21" @@ -316,12 +322,6 @@ dependencies = [ "vector-types", ] -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - [[package]] name = "block-buffer" version = "0.10.4" @@ -426,20 +426,6 @@ dependencies = [ "libbz2-rs-sys", ] -[[package]] -name = "calloop" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" -dependencies = [ - "bitflags 2.11.0", - "log", - "polling", - "rustix 0.38.44", - "slab", - "thiserror 1.0.69", -] - [[package]] name = "calloop" version = "0.14.3" @@ -448,7 +434,7 @@ checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" dependencies = [ "bitflags 2.11.0", "polling", - "rustix 1.0.8", + "rustix", "slab", "tracing", ] @@ -459,8 +445,8 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ - "calloop 0.14.3", - "rustix 1.0.8", + "calloop", + "rustix", "wayland-backend", "wayland-client", ] @@ -599,7 +585,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tracing", - "wgpu 29.0.3", + "wgpu", "windows 0.62.2", "windows-sys 0.61.2", ] @@ -703,7 +689,7 @@ version = "4.5.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.106", @@ -763,17 +749,6 @@ dependencies = [ "cc", ] -[[package]] -name = "codespan-reporting" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" -dependencies = [ - "serde", - "termcolor", - "unicode-width", -] - [[package]] name = "codespan-reporting" version = "0.13.1" @@ -909,41 +884,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core-graphics" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.10.1", - "libc", -] - [[package]] name = "core-types" version = "0.1.0" @@ -957,7 +897,7 @@ dependencies = [ "glam 0.32.1", "graphene-hash", "image", - "kurbo", + "kurbo 0.13.1", "log", "lyon_geom", "no-std-types", @@ -989,20 +929,20 @@ dependencies = [ [[package]] name = "cosmic-text" -version = "0.15.0" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173852283a9a57a3cbe365d86e74dc428a09c50421477d5ad6fe9d9509e37737" +checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73" dependencies = [ "bitflags 2.11.0", "fontdb", - "harfrust 0.3.2", + "harfrust 0.5.2", "linebender_resource_handle", "log", "rangemap", - "rustc-hash 1.1.0", + "rustc-hash 2.1.1", "self_cell", - "skrifa 0.37.0", - "smol_str 0.2.2", + "skrifa 0.40.0", + "smol_str 0.3.2", "swash", "sys-locale", "unicode-bidi", @@ -1115,14 +1055,13 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "cryoglyph" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc795bdbccdbd461736fb163930a009da6597b226d6f6fce33e7a8eb6ec519" +source = "git+https://github.com/iced-rs/cryoglyph.git?rev=53ba3e879539d19ed8162942126a977ec896cc3b#53ba3e879539d19ed8162942126a977ec896cc3b" dependencies = [ "cosmic-text", "etagere", "lru", "rustc-hash 2.1.1", - "wgpu 27.0.1", + "wgpu", ] [[package]] @@ -1280,12 +1219,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - [[package]] name = "dispatch2" version = "0.3.0" @@ -1547,7 +1480,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix 1.0.8", + "rustix", "windows-sys 0.59.0", ] @@ -1622,15 +1555,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "font-types" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" -dependencies = [ - "bytemuck", -] - [[package]] name = "font-types" version = "0.11.3" @@ -1685,33 +1609,6 @@ dependencies = [ "yeslogic-fontconfig-sys", ] -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1853,7 +1750,7 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55" dependencies = [ - "rustix 1.0.8", + "rustix", "windows-targets 0.52.6", ] @@ -1943,18 +1840,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" -[[package]] -name = "glow" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" -dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "glow" version = "0.17.0" @@ -1976,37 +1861,6 @@ dependencies = [ "gl_generator", ] -[[package]] -name = "gpu-alloc" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" -dependencies = [ - "bitflags 2.11.0", - "gpu-alloc-types", -] - -[[package]] -name = "gpu-alloc-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "gpu-allocator" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" -dependencies = [ - "log", - "presser", - "thiserror 1.0.69", - "windows 0.58.0", -] - [[package]] name = "gpu-allocator" version = "0.28.0" @@ -2073,7 +1927,7 @@ dependencies = [ "wasm-bindgen", "web-sys", "wgpu-executor", - "winit 0.30.12", + "winit", ] [[package]] @@ -2088,7 +1942,7 @@ dependencies = [ "text-nodes", "vector-types", "web-sys", - "wgpu 29.0.3", + "wgpu", ] [[package]] @@ -2104,7 +1958,7 @@ dependencies = [ "text-nodes", "vector-types", "web-sys", - "wgpu 29.0.3", + "wgpu", "wgpu-executor", ] @@ -2123,7 +1977,7 @@ dependencies = [ "log", "preprocessor", "tokio", - "wgpu 29.0.3", + "wgpu", "wgpu-executor", ] @@ -2197,7 +2051,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "wgpu 29.0.3", + "wgpu", "wgpu-executor", ] @@ -2263,10 +2117,10 @@ dependencies = [ "tracing", "tracing-subscriber", "vello", - "wgpu 29.0.3", + "wgpu", "window_clipboard", "windows 0.58.0", - "winit 0.30.12", + "winit", ] [[package]] @@ -2325,7 +2179,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "vello", - "wgpu 29.0.3", + "wgpu", "wgpu-executor", ] @@ -2348,7 +2202,7 @@ dependencies = [ "image", "interpreted-executor", "js-sys", - "kurbo", + "kurbo 0.13.1", "log", "num_enum", "once_cell", @@ -2371,11 +2225,25 @@ dependencies = [ name = "graphite-iced-frontend" version = "0.0.0" dependencies = [ + "graph-craft", "graphite-editor", - "iced", + "iced_graphics", + "iced_runtime", + "iced_wgpu", + "iced_widget", + "image", + "include_dir", + "pollster", "rand", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", "tracing", "tracing-subscriber", + "wgpu", + "wgpu-executor", + "winit", ] [[package]] @@ -2408,7 +2276,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "wgpu 29.0.3", + "wgpu", ] [[package]] @@ -2502,14 +2370,14 @@ dependencies = [ [[package]] name = "harfrust" -version = "0.3.2" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" dependencies = [ "bitflags 2.11.0", "bytemuck", "core_maths", - "read-fonts 0.35.0", + "read-fonts 0.37.0", "smallvec", ] @@ -2557,6 +2425,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -2713,27 +2587,10 @@ dependencies = [ "cc", ] -[[package]] -name = "iced" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "000e01026c93ba643f8357a3db3ada0e6555265a377f6f9291c472f6dd701fb3" -dependencies = [ - "iced_core", - "iced_debug", - "iced_futures", - "iced_renderer", - "iced_runtime", - "iced_widget", - "iced_winit", - "thiserror 2.0.18", -] - [[package]] name = "iced_core" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ab1937d699403e7e69252ae743a902bcee9f4ab2052cc4c9a46fcf34729d85" +version = "0.15.0-dev" +source = "git+https://github.com/iced-rs/iced?branch=master#0d12e964da8c3f0f7a02d3d731e3187eecfe5779" dependencies = [ "bitflags 2.11.0", "bytes", @@ -2749,9 +2606,8 @@ dependencies = [ [[package]] name = "iced_debug" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25035ab0215a620e53f4103e36fc4e59a1fb2817e4bfc38a30ad27b4202ea0be" +version = "0.15.0-dev" +source = "git+https://github.com/iced-rs/iced?branch=master#0d12e964da8c3f0f7a02d3d731e3187eecfe5779" dependencies = [ "iced_core", "iced_futures", @@ -2760,24 +2616,21 @@ dependencies = [ [[package]] name = "iced_futures" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c0c85ccad42dfbec7293c36c018af0ea0dbcc52d137a4a9a0b0f6822a3fdf0a" +version = "0.15.0-dev" +source = "git+https://github.com/iced-rs/iced?branch=master#0d12e964da8c3f0f7a02d3d731e3187eecfe5779" dependencies = [ "futures", "iced_core", "log", "rustc-hash 2.1.1", - "tokio", "wasm-bindgen-futures", "wasmtimer", ] [[package]] name = "iced_graphics" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234ca1c2cec4155055f68fa5fad1b5242c496ac8238d80a259bca382fb44a102" +version = "0.15.0-dev" +source = "git+https://github.com/iced-rs/iced?branch=master#0d12e964da8c3f0f7a02d3d731e3187eecfe5779" dependencies = [ "bitflags 2.11.0", "bytemuck", @@ -2785,6 +2638,8 @@ dependencies = [ "half", "iced_core", "iced_futures", + "image", + "kamadak-exif", "log", "raw-window-handle", "rustc-hash 2.1.1", @@ -2792,23 +2647,13 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "iced_program" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dfafec2947cda688d8eb00dac337ba11aa60f9ef6335aed343e189d26e4a673" -dependencies = [ - "iced_graphics", - "iced_runtime", -] - [[package]] name = "iced_renderer" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "250cc0802408e8c077986ec56c7d07c65f423ee658a4b9fd795a1f2aae5dac05" +version = "0.15.0-dev" +source = "git+https://github.com/iced-rs/iced?branch=master#0d12e964da8c3f0f7a02d3d731e3187eecfe5779" dependencies = [ "iced_graphics", + "iced_tiny_skia", "iced_wgpu", "log", "thiserror 2.0.18", @@ -2816,9 +2661,8 @@ dependencies = [ [[package]] name = "iced_runtime" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1889b819ce4c06674183242e336c8d49465665441396914dc07cc86f44fa8d4" +version = "0.15.0-dev" +source = "git+https://github.com/iced-rs/iced?branch=master#0d12e964da8c3f0f7a02d3d731e3187eecfe5779" dependencies = [ "bytes", "iced_core", @@ -2827,11 +2671,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "iced_tiny_skia" +version = "0.15.0-dev" +source = "git+https://github.com/iced-rs/iced?branch=master#0d12e964da8c3f0f7a02d3d731e3187eecfe5779" +dependencies = [ + "bytemuck", + "cosmic-text", + "iced_debug", + "iced_graphics", + "kurbo 0.10.4", + "log", + "rustc-hash 2.1.1", + "softbuffer", + "tiny-skia", +] + [[package]] name = "iced_wgpu" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff144a999b0ca0f8a10257934500060240825c42e950ec0ebee9c8ae30561c13" +version = "0.15.0-dev" +source = "git+https://github.com/iced-rs/iced?branch=master#0d12e964da8c3f0f7a02d3d731e3187eecfe5779" dependencies = [ "bitflags 2.11.0", "bytemuck", @@ -2844,41 +2703,23 @@ dependencies = [ "log", "rustc-hash 2.1.1", "thiserror 2.0.18", - "wgpu 27.0.1", + "wgpu", ] [[package]] name = "iced_widget" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab" +version = "0.15.0-dev" +source = "git+https://github.com/iced-rs/iced?branch=master#0d12e964da8c3f0f7a02d3d731e3187eecfe5779" dependencies = [ "iced_renderer", "log", "num-traits", + "ouroboros", "rustc-hash 2.1.1", "thiserror 2.0.18", "unicode-segmentation", ] -[[package]] -name = "iced_winit" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b7dbedc47562d1de3b9707d939f678b88c382004b7ab5a18f7a7dd723162d75" -dependencies = [ - "iced_debug", - "iced_program", - "log", - "rustc-hash 2.1.1", - "thiserror 2.0.18", - "tracing", - "wasm-bindgen-futures", - "web-sys", - "window_clipboard", - "winit 0.30.13", -] - [[package]] name = "icu_collections" version = "2.2.0" @@ -3268,6 +3109,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kamadak-exif" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" +dependencies = [ + "mutate_once", +] + [[package]] name = "keyboard-types" version = "0.8.1" @@ -3295,6 +3145,16 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kurbo" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +dependencies = [ + "arrayvec", + "smallvec", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -3360,7 +3220,7 @@ checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ "bitflags 2.11.0", "libc", - "redox_syscall 0.5.17", + "redox_syscall", ] [[package]] @@ -3385,18 +3245,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8421b276e96af0ace5f3d8d2d165d0dea07fe764d2fe94ec06bb1acaf8a1e759" dependencies = [ "arrayvec", - "kurbo", + "kurbo 0.13.1", "polycool", "rustc-hash 2.1.1", "smallvec", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -3469,15 +3323,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - [[package]] name = "markup5ever" version = "0.36.1" @@ -3549,21 +3394,6 @@ dependencies = [ "libc", ] -[[package]] -name = "metal" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" -dependencies = [ - "bitflags 2.11.0", - "block", - "core-graphics-types 0.2.0", - "foreign-types", - "log", - "objc", - "paste", -] - [[package]] name = "mime" version = "0.3.17" @@ -3610,30 +3440,10 @@ dependencies = [ ] [[package]] -name = "naga" -version = "27.0.3" +name = "mutate_once" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" -dependencies = [ - "arrayvec", - "bit-set 0.8.0", - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "codespan-reporting 0.12.0", - "half", - "hashbrown 0.16.0", - "hexf-parse", - "indexmap", - "libm", - "log", - "num-traits", - "once_cell", - "rustc-hash 1.1.0", - "spirv 0.3.0+sdk-1.3.268.0", - "thiserror 2.0.18", - "unicode-ident", -] +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" [[package]] name = "naga" @@ -3646,7 +3456,7 @@ dependencies = [ "bitflags 2.11.0", "cfg-if", "cfg_aliases", - "codespan-reporting 0.13.1", + "codespan-reporting", "half", "hashbrown 0.16.0", "hexf-parse", @@ -3850,15 +3660,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", -] - [[package]] name = "objc-sys" version = "0.3.5" @@ -3913,30 +3714,6 @@ dependencies = [ "objc2-foundation 0.3.2", ] -[[package]] -name = "objc2-cloud-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" -dependencies = [ - "bitflags 2.11.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-contacts" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - [[package]] name = "objc2-core-data" version = "0.2.2" @@ -3968,8 +3745,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.11.0", + "dispatch2", "libc", + "objc2 0.6.4", "objc2-core-foundation", + "objc2-io-surface", ] [[package]] @@ -3984,18 +3764,6 @@ dependencies = [ "objc2-metal 0.2.2", ] -[[package]] -name = "objc2-core-location" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-contacts", - "objc2-foundation 0.2.2", -] - [[package]] name = "objc2-core-text" version = "0.3.2" @@ -4031,7 +3799,6 @@ checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.11.0", "block2 0.5.1", - "dispatch", "libc", "objc2 0.5.2", ] @@ -4062,18 +3829,6 @@ dependencies = [ "objc2-foundation 0.3.2", ] -[[package]] -name = "objc2-link-presentation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", -] - [[package]] name = "objc2-metal" version = "0.2.2" @@ -4127,37 +3882,6 @@ dependencies = [ "objc2-metal 0.3.2", ] -[[package]] -name = "objc2-symbols" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" -dependencies = [ - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" -dependencies = [ - "bitflags 2.11.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-image", - "objc2-core-location", - "objc2-foundation 0.2.2", - "objc2-link-presentation", - "objc2-quartz-core 0.2.2", - "objc2-symbols", - "objc2-uniform-type-identifiers", - "objc2-user-notifications", -] - [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -4170,30 +3894,6 @@ dependencies = [ "objc2-foundation 0.3.2", ] -[[package]] -name = "objc2-uniform-type-identifiers" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-user-notifications" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" -dependencies = [ - "bitflags 2.11.0", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", -] - [[package]] name = "object" version = "0.36.7" @@ -4271,6 +3971,30 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.106", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -4298,7 +4022,7 @@ checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.17", + "redox_syscall", "smallvec", "windows-targets 0.52.6", ] @@ -4336,12 +4060,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "path-bool-nodes" version = "0.1.0" @@ -4369,7 +4087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" dependencies = [ "color", - "kurbo", + "kurbo 0.13.1", "linebender_resource_handle", "smallvec", ] @@ -4619,7 +4337,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.0.8", + "rustix", "windows-sys 0.60.2", ] @@ -4750,6 +4468,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "version_check", + "yansi", +] + [[package]] name = "profiling" version = "1.0.17" @@ -4904,7 +4635,7 @@ dependencies = [ "glam 0.32.1", "graphene-hash", "image", - "kurbo", + "kurbo 0.13.1", "ndarray", "no-std-types", "node-macro", @@ -4955,7 +4686,7 @@ dependencies = [ "serde_json", "tsify", "wasm-bindgen", - "wgpu 29.0.3", + "wgpu", ] [[package]] @@ -5008,17 +4739,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "read-fonts" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" -dependencies = [ - "bytemuck", - "core_maths", - "font-types 0.10.1", -] - [[package]] name = "read-fonts" version = "0.37.0" @@ -5026,7 +4746,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" dependencies = [ "bytemuck", - "font-types 0.11.3", + "core_maths", + "font-types", ] [[package]] @@ -5037,7 +4758,7 @@ checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" dependencies = [ "bytemuck", "core_maths", - "font-types 0.11.3", + "font-types", ] [[package]] @@ -5046,15 +4767,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" -[[package]] -name = "redox_syscall" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" -dependencies = [ - "bitflags 1.3.2", -] - [[package]] name = "redox_syscall" version = "0.5.17" @@ -5120,7 +4832,7 @@ dependencies = [ "glam 0.32.1", "graphene-hash", "graphic-types", - "kurbo", + "kurbo 0.13.1", "log", "num-traits", "serde", @@ -5139,7 +4851,7 @@ dependencies = [ "glam 0.32.1", "graphene-core", "graphic-types", - "kurbo", + "kurbo 0.13.1", "log", "node-macro", "raster-types", @@ -5311,19 +5023,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.0.8" @@ -5333,7 +5032,7 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.9.4", + "linux-raw-sys", "windows-sys 0.60.2", ] @@ -5734,16 +5433,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" -[[package]] -name = "skrifa" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" -dependencies = [ - "bytemuck", - "read-fonts 0.35.0", -] - [[package]] name = "skrifa" version = "0.40.0" @@ -5795,13 +5484,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ "bitflags 2.11.0", - "calloop 0.14.3", + "calloop", "calloop-wayland-source", "cursor-icon", "libc", "log", "memmap2", - "rustix 1.0.8", + "rustix", "thiserror 2.0.18", "wayland-backend", "wayland-client", @@ -5866,6 +5555,28 @@ dependencies = [ "winapi", ] +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + [[package]] name = "spin" version = "0.10.0" @@ -6010,7 +5721,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.106", @@ -6034,7 +5745,7 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ - "kurbo", + "kurbo 0.13.1", "siphasher", ] @@ -6666,9 +6377,9 @@ checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" [[package]] name = "unicode-script" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb421b350c9aff471779e262955939f565ec18b86c15364e6bdf0d662ca7c1f" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" [[package]] name = "unicode-segmentation" @@ -6763,7 +6474,7 @@ dependencies = [ "flate2", "fontdb", "imagesize", - "kurbo", + "kurbo 0.13.1", "log", "pico-args", "roxmltree 0.21.1", @@ -6815,7 +6526,7 @@ dependencies = [ "graphene-core", "graphene-hash", "graphic-types", - "kurbo", + "kurbo 0.13.1", "log", "node-macro", "qrcodegen", @@ -6840,7 +6551,7 @@ dependencies = [ "fixedbitset", "glam 0.32.1", "graphene-hash", - "kurbo", + "kurbo 0.13.1", "log", "lyon_geom", "node-macro", @@ -6870,7 +6581,7 @@ dependencies = [ "thiserror 2.0.18", "vello_encoding", "vello_shaders", - "wgpu 29.0.3", + "wgpu", ] [[package]] @@ -6894,7 +6605,7 @@ checksum = "9dd38937516fa4b47423d9255bb5e4a65e839ec9d57c38c4af6189ce56bf46b7" dependencies = [ "bytemuck", "log", - "naga 29.0.3", + "naga", "thiserror 2.0.18", "vello_encoding", ] @@ -7016,7 +6727,7 @@ checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" dependencies = [ "cc", "downcast-rs", - "rustix 1.0.8", + "rustix", "scoped-tls", "smallvec", "wayland-sys", @@ -7029,7 +6740,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ "bitflags 2.11.0", - "rustix 1.0.8", + "rustix", "wayland-backend", "wayland-scanner", ] @@ -7051,7 +6762,7 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" dependencies = [ - "rustix 1.0.8", + "rustix", "wayland-client", "xcursor", ] @@ -7199,35 +6910,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" -[[package]] -name = "wgpu" -version = "27.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" -dependencies = [ - "arrayvec", - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "document-features", - "hashbrown 0.16.0", - "js-sys", - "log", - "naga 27.0.3", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "smallvec", - "static_assertions", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "wgpu-core 27.0.3", - "wgpu-hal 27.0.4", - "wgpu-types 27.0.1", -] - [[package]] name = "wgpu" version = "29.0.3" @@ -7243,7 +6925,7 @@ dependencies = [ "hashbrown 0.16.0", "js-sys", "log", - "naga 29.0.3", + "naga", "parking_lot", "portable-atomic", "profiling", @@ -7253,41 +6935,9 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "wgpu-core 29.0.3", - "wgpu-hal 29.0.3", - "wgpu-types 29.0.3", -] - -[[package]] -name = "wgpu-core" -version = "27.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" -dependencies = [ - "arrayvec", - "bit-set 0.8.0", - "bit-vec 0.8.0", - "bitflags 2.11.0", - "bytemuck", - "cfg_aliases", - "document-features", - "hashbrown 0.16.0", - "indexmap", - "log", - "naga 27.0.3", - "once_cell", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "rustc-hash 1.1.0", - "smallvec", - "thiserror 2.0.18", - "wgpu-core-deps-apple 27.0.0", - "wgpu-core-deps-emscripten 27.0.0", - "wgpu-core-deps-windows-linux-android 27.0.0", - "wgpu-hal 27.0.4", - "wgpu-types 27.0.1", + "wgpu-core", + "wgpu-hal", + "wgpu-types", ] [[package]] @@ -7306,7 +6956,7 @@ dependencies = [ "hashbrown 0.16.0", "indexmap", "log", - "naga 29.0.3", + "naga", "once_cell", "parking_lot", "portable-atomic", @@ -7315,21 +6965,12 @@ dependencies = [ "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.18", - "wgpu-core-deps-apple 29.0.3", - "wgpu-core-deps-emscripten 29.0.3", - "wgpu-core-deps-windows-linux-android 29.0.3", - "wgpu-hal 29.0.3", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", "wgpu-naga-bridge", - "wgpu-types 29.0.3", -] - -[[package]] -name = "wgpu-core-deps-apple" -version = "27.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" -dependencies = [ - "wgpu-hal 27.0.4", + "wgpu-types", ] [[package]] @@ -7338,16 +6979,7 @@ version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62e51b5447e144b3dbba4feb01f80f4fa21696fa0cd99afb2c3df1affd6fdb28" dependencies = [ - "wgpu-hal 29.0.3", -] - -[[package]] -name = "wgpu-core-deps-emscripten" -version = "27.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" -dependencies = [ - "wgpu-hal 27.0.4", + "wgpu-hal", ] [[package]] @@ -7356,16 +6988,7 @@ version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3487cd6293a963bc5c0c0396f6a2192043c50003c07f4efdccbad3d90ec9d819" dependencies = [ - "wgpu-hal 29.0.3", -] - -[[package]] -name = "wgpu-core-deps-windows-linux-android" -version = "27.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" -dependencies = [ - "wgpu-hal 27.0.4", + "wgpu-hal", ] [[package]] @@ -7374,7 +6997,7 @@ version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb01076d0aa08b0ba9bd741e178b5cc440f5abe99d9581323a4c8b5d1a1916" dependencies = [ - "wgpu-hal 29.0.3", + "wgpu-hal", ] [[package]] @@ -7393,56 +7016,7 @@ dependencies = [ "rendering", "vello", "web-sys", - "wgpu 29.0.3", -] - -[[package]] -name = "wgpu-hal" -version = "27.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" -dependencies = [ - "android_system_properties", - "arrayvec", - "ash", - "bit-set 0.8.0", - "bitflags 2.11.0", - "block", - "bytemuck", - "cfg-if", - "cfg_aliases", - "core-graphics-types 0.2.0", - "glow 0.16.0", - "glutin_wgl_sys", - "gpu-alloc", - "gpu-allocator 0.27.0", - "gpu-descriptor", - "hashbrown 0.16.0", - "js-sys", - "khronos-egl", - "libc", - "libloading 0.8.8", - "log", - "metal", - "naga 27.0.3", - "ndk-sys", - "objc", - "once_cell", - "ordered-float 4.6.0", - "parking_lot", - "portable-atomic", - "portable-atomic-util", - "profiling", - "range-alloc", - "raw-window-handle", - "renderdoc-sys", - "smallvec", - "thiserror 2.0.18", - "wasm-bindgen", - "web-sys", - "wgpu-types 27.0.1", - "windows 0.58.0", - "windows-core 0.58.0", + "wgpu", ] [[package]] @@ -7460,9 +7034,9 @@ dependencies = [ "bytemuck", "cfg-if", "cfg_aliases", - "glow 0.17.0", + "glow", "glutin_wgl_sys", - "gpu-allocator 0.28.0", + "gpu-allocator", "gpu-descriptor", "hashbrown 0.16.0", "js-sys", @@ -7470,7 +7044,7 @@ dependencies = [ "libc", "libloading 0.8.8", "log", - "naga 29.0.3", + "naga", "ndk-sys", "objc2 0.6.4", "objc2-core-foundation", @@ -7493,7 +7067,7 @@ dependencies = [ "wayland-sys", "web-sys", "wgpu-naga-bridge", - "wgpu-types 29.0.3", + "wgpu-types", "windows 0.62.2", "windows-core 0.62.2", "windows-result 0.4.1", @@ -7505,22 +7079,8 @@ version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59c654c483f058800972c3645e95388a7eca31bf9fe1933bc20e036588a0be02" dependencies = [ - "naga 29.0.3", - "wgpu-types 29.0.3", -] - -[[package]] -name = "wgpu-types" -version = "27.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" -dependencies = [ - "bitflags 2.11.0", - "bytemuck", - "js-sys", - "log", - "thiserror 2.0.18", - "web-sys", + "naga", + "wgpu-types", ] [[package]] @@ -8052,7 +7612,7 @@ dependencies = [ "dpi", "libc", "raw-window-handle", - "rustix 1.0.8", + "rustix", "serde", "smol_str 0.3.2", "tracing", @@ -8068,46 +7628,6 @@ dependencies = [ "winit-x11", ] -[[package]] -name = "winit" -version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" -dependencies = [ - "android-activity", - "atomic-waker", - "bitflags 2.11.0", - "block2 0.5.1", - "calloop 0.13.0", - "cfg_aliases", - "concurrent-queue", - "core-foundation 0.9.4", - "core-graphics", - "cursor-icon", - "dpi", - "js-sys", - "libc", - "ndk", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", - "objc2-ui-kit 0.2.2", - "orbclient", - "pin-project", - "raw-window-handle", - "redox_syscall 0.4.1", - "rustix 0.38.44", - "smol_str 0.2.2", - "tracing", - "unicode-segmentation", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "web-time", - "windows-sys 0.52.0", - "xkbcommon-dl", -] - [[package]] name = "winit-android" version = "0.30.12" @@ -8184,7 +7704,7 @@ dependencies = [ "dpi", "orbclient", "raw-window-handle", - "redox_syscall 0.5.17", + "redox_syscall", "smol_str 0.3.2", "tracing", "winit-core", @@ -8202,7 +7722,7 @@ dependencies = [ "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", - "objc2-ui-kit 0.3.2", + "objc2-ui-kit", "raw-window-handle", "serde", "smol_str 0.3.2", @@ -8218,13 +7738,13 @@ source = "git+https://github.com/rust-windowing/winit.git#bd6fef1d80ba063cbe91e1 dependencies = [ "ahash", "bitflags 2.11.0", - "calloop 0.14.3", + "calloop", "cursor-icon", "dpi", "libc", "memmap2", "raw-window-handle", - "rustix 1.0.8", + "rustix", "sctk-adwaita", "smithay-client-toolkit", "smol_str 0.3.2", @@ -8282,13 +7802,13 @@ source = "git+https://github.com/rust-windowing/winit.git#bd6fef1d80ba063cbe91e1 dependencies = [ "bitflags 2.11.0", "bytemuck", - "calloop 0.14.3", + "calloop", "cursor-icon", "dpi", "libc", "percent-encoding", "raw-window-handle", - "rustix 1.0.8", + "rustix", "smol_str 0.3.2", "tracing", "winit-common", @@ -8350,7 +7870,7 @@ dependencies = [ "libc", "libloading 0.8.8", "once_cell", - "rustix 1.0.8", + "rustix", "x11rb-protocol", "xcursor", ] @@ -8368,7 +7888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af3a19837351dc82ba89f8a125e22a3c475f05aba604acc023d62b2739ae2909" dependencies = [ "libc", - "rustix 1.0.8", + "rustix", ] [[package]] diff --git a/frontend/iced/Cargo.toml b/frontend/iced/Cargo.toml index 92be97b5..8173a75e 100644 --- a/frontend/iced/Cargo.toml +++ b/frontend/iced/Cargo.toml @@ -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 } diff --git a/frontend/iced/src/app.rs b/frontend/iced/src/app.rs new file mode 100644 index 00000000..3815abcc --- /dev/null +++ b/frontend/iced/src/app.rs @@ -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, + dialog: Option, + artwork_texture: Option>, + async_results: Receiver, + async_sender: Sender, +} + +pub enum AsyncResult { + FontCatalog(FontCatalog), + FontData { font_family: String, font_style: String, data: Vec }, +} + +struct DialogHeader { + title: String, + icon: String, +} + +impl App { + pub fn new() -> (Self, Vec) { + 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>) { + 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) { + 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, +} + +#[derive(serde::Deserialize)] +struct FontListEntry { + family: String, + variants: Vec, + files: std::collections::HashMap, +} + +fn spawn_font_catalog_fetch(sender: Sender) { + 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::().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, 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 + } +} diff --git a/frontend/iced/src/input.rs b/frontend/iced/src/input.rs new file mode 100644 index 00000000..8d17fa4c --- /dev/null +++ b/frontend/iced/src/input.rs @@ -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, + } +} diff --git a/frontend/iced/src/layout.rs b/frontend/iced/src/layout.rs index ce579e6d..ce59ff1c 100644 --- a/frontend/iced/src/layout.rs +++ b/frontend/iced/src/layout.rs @@ -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 { self.layouts.iter() } diff --git a/frontend/iced/src/main.rs b/frontend/iced/src/main.rs index 1a79b338..f68e607f 100644 --- a/frontend/iced/src/main.rs +++ b/frontend/iced/src/main.rs @@ -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, -} - -impl App { - fn boot() -> (Self, Task) { - 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 { - 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) { - 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(); } diff --git a/frontend/iced/src/shell.rs b/frontend/iced/src/shell.rs new file mode 100644 index 00000000..b569cdda --- /dev/null +++ b/frontend/iced/src/shell.rs @@ -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>, + handle: Option, + modifiers: ModifiersState, + last_cursor: PhysicalPosition, +} + +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 = 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(), + _ => {} + } + } +} diff --git a/frontend/iced/src/viewport.rs b/frontend/iced/src/viewport.rs new file mode 100644 index 00000000..52d634ad --- /dev/null +++ b/frontend/iced/src/viewport.rs @@ -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, + cursor: mouse::Cursor, + needs_redraw: bool, + state: App, + artwork_texture: Option>, +} + +impl ViewportHandle { + pub fn new_from_raw(raw_window: RawWindowHandle, raw_display: RawDisplayHandle, width: f32, height: f32, scale: f32) -> Option { + 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 = 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, 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 = Vec::new(); + let drained: Vec = 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() + } +} diff --git a/frontend/iced/src/viewport_widget.rs b/frontend/iced/src/viewport_widget.rs new file mode 100644 index 00000000..75be5a99 --- /dev/null +++ b/frontend/iced/src/viewport_widget.rs @@ -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, + @location(0) uv: vec2, +}; + +@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(x, y); + out.pos = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + return out; +} + +@group(0) @binding(0) var artwork: texture_2d; +@group(0) @binding(1) var artwork_sampler: sampler; + +@fragment +fn fs(in: VsOut) -> @location(0) vec4 { + return textureSample(artwork, artwork_sampler, in.uv); +} +"#; + +pub struct ViewportProgram { + pub texture: Option>, +} + +impl Program 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>, +} + +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, + 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, + } + } +} diff --git a/frontend/iced/src/widgets.rs b/frontend/iced/src/widgets.rs index a5cce79f..c309058a 100644 --- a/frontend/iced/src/widgets.rs +++ b/frontend/iced/src/widgets.rs @@ -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>> = 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(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 Erased for T { + fn to_json(&self) -> serde_json::Value { + serde_json::to_value(self).unwrap_or(serde_json::Value::Null) + } } } -- 2.51.0 From e36107a6cfc2efe8c9a31e793273095c51a31e7c Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 18 May 2026 19:11:47 -0700 Subject: [PATCH 3/3] bundle and render branding icons --- Cargo.lock | 99 +++++++++- frontend/iced/Cargo.toml | 5 + frontend/iced/src/clipboard.rs | 111 +++++++++++ frontend/iced/src/file_io.rs | 161 +++++++++++++++ frontend/iced/src/icons.rs | 295 ++++++++++++++++++++++++++++ frontend/iced/src/main.rs | 7 + frontend/iced/src/persist.rs | 143 ++++++++++++++ frontend/iced/src/pointer.rs | 161 +++++++++++++++ frontend/iced/src/window_control.rs | 141 +++++++++++++ 9 files changed, 1117 insertions(+), 6 deletions(-) create mode 100644 frontend/iced/src/clipboard.rs create mode 100644 frontend/iced/src/file_io.rs create mode 100644 frontend/iced/src/icons.rs create mode 100644 frontend/iced/src/persist.rs create mode 100644 frontend/iced/src/pointer.rs create mode 100644 frontend/iced/src/window_control.rs diff --git a/Cargo.lock b/Cargo.lock index b5160635..a80d2f7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1800,6 +1800,16 @@ dependencies = [ "weezl", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.31.1" @@ -2225,6 +2235,7 @@ dependencies = [ name = "graphite-iced-frontend" version = "0.0.0" dependencies = [ + "dirs", "graph-craft", "graphite-editor", "iced_graphics", @@ -2237,12 +2248,16 @@ dependencies = [ "rand", "raw-window-handle", "reqwest", + "resvg", + "rfd", "serde", "serde_json", "tracing", "tracing-subscriber", + "usvg", "wgpu", "wgpu-executor", + "window_clipboard", "winit", ] @@ -2684,7 +2699,7 @@ dependencies = [ "log", "rustc-hash 2.1.1", "softbuffer", - "tiny-skia", + "tiny-skia 0.11.4", ] [[package]] @@ -2877,11 +2892,21 @@ dependencies = [ "bytemuck", "byteorder-lite", "color_quant", - "gif", + "gif 0.13.3", "num-traits", "png 0.17.16", - "zune-core", - "zune-jpeg", + "zune-core 0.4.12", + "zune-jpeg 0.4.20", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] [[package]] @@ -4493,6 +4518,12 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.37.5" @@ -4903,6 +4934,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +dependencies = [ + "gif 0.14.2", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia 0.12.0", + "usvg", + "zune-jpeg 0.5.15", +] + [[package]] name = "rfd" version = "0.17.2" @@ -4929,6 +4977,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -5207,7 +5264,7 @@ dependencies = [ "log", "memmap2", "smithay-client-toolkit", - "tiny-skia", + "tiny-skia 0.11.4", ] [[package]] @@ -5993,6 +6050,21 @@ dependencies = [ "tiny-skia-path 0.11.4", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.18.1", + "tiny-skia-path 0.12.0", +] + [[package]] name = "tiny-skia-path" version = "0.11.4" @@ -8068,11 +8140,26 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + [[package]] name = "zune-jpeg" version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1f7e205ce79eb2da3cd71c5f55f3589785cb7c79f6a03d1c8d1491bda5d089" dependencies = [ - "zune-core", + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core 0.5.1", ] diff --git a/frontend/iced/Cargo.toml b/frontend/iced/Cargo.toml index 8173a75e..d8b55172 100644 --- a/frontend/iced/Cargo.toml +++ b/frontend/iced/Cargo.toml @@ -27,11 +27,16 @@ wgpu = { workspace = true } raw-window-handle = "0.6" pollster = "0.4" +dirs = { workspace = true } image = { workspace = true } include_dir = { workspace = true } rand = { workspace = true, features = ["thread_rng"] } reqwest = { workspace = true } +resvg = { workspace = true } +rfd = { workspace = true } +usvg = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +window_clipboard = "0.5" diff --git a/frontend/iced/src/clipboard.rs b/frontend/iced/src/clipboard.rs new file mode 100644 index 00000000..90d139f3 --- /dev/null +++ b/frontend/iced/src/clipboard.rs @@ -0,0 +1,111 @@ +use raw_window_handle::{DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle, WindowHandle}; +use std::sync::Mutex; + +pub struct ClipboardHandle { + inner: Mutex, +} + +impl ClipboardHandle { + pub fn new_from_raw(window_handle: RawWindowHandle, display_handle: RawDisplayHandle) -> Option { + let provider = RawHandles { window: window_handle, display: display_handle }; + let connected = unsafe { window_clipboard::Clipboard::connect(&provider) }; + match connected { + Ok(clipboard) => Some(Self { inner: Mutex::new(clipboard) }), + Err(e) => { + tracing::warn!("failed to connect system clipboard: {e}"); + None + } + } + } + + pub fn read_text(&self) -> Option { + let guard = match self.inner.lock() { + Ok(guard) => guard, + Err(e) => { + tracing::warn!("clipboard mutex poisoned: {e}"); + return None; + } + }; + match guard.read() { + Ok(text) => Some(text), + Err(e) => { + tracing::warn!("failed to read clipboard: {e}"); + None + } + } + } + + pub fn write_text(&self, text: &str) { + let mut guard = match self.inner.lock() { + Ok(guard) => guard, + Err(e) => { + tracing::warn!("clipboard mutex poisoned: {e}"); + return; + } + }; + if let Err(e) = guard.write(text.to_string()) { + tracing::warn!("failed to write clipboard: {e}"); + } + } + + pub fn read_selection(&self) -> Option { + let guard = match self.inner.lock() { + Ok(guard) => guard, + Err(e) => { + tracing::warn!("clipboard mutex poisoned: {e}"); + return None; + } + }; + if let Some(result) = guard.read_primary() { + match result { + Ok(text) => Some(text), + Err(e) => { + tracing::warn!("failed to read primary selection: {e}"); + None + } + } + } else { + match guard.read() { + Ok(text) => Some(text), + Err(e) => { + tracing::warn!("failed to read clipboard fallback for selection: {e}"); + None + } + } + } + } + + pub fn write_selection(&self, text: &str) { + let mut guard = match self.inner.lock() { + Ok(guard) => guard, + Err(e) => { + tracing::warn!("clipboard mutex poisoned: {e}"); + return; + } + }; + if let Some(result) = guard.write_primary(text.to_string()) { + if let Err(e) = result { + tracing::warn!("failed to write primary selection: {e}"); + } + } else if let Err(e) = guard.write(text.to_string()) { + tracing::warn!("failed to write clipboard fallback for selection: {e}"); + } + } +} + +struct RawHandles { + window: RawWindowHandle, + display: RawDisplayHandle, +} + +impl HasDisplayHandle for RawHandles { + fn display_handle(&self) -> Result, HandleError> { + Ok(unsafe { DisplayHandle::borrow_raw(self.display) }) + } +} + +impl HasWindowHandle for RawHandles { + fn window_handle(&self) -> Result, HandleError> { + Ok(unsafe { WindowHandle::borrow_raw(self.window) }) + } +} diff --git a/frontend/iced/src/file_io.rs b/frontend/iced/src/file_io.rs new file mode 100644 index 00000000..21467091 --- /dev/null +++ b/frontend/iced/src/file_io.rs @@ -0,0 +1,161 @@ +use graphite_editor::messages::prelude::DocumentId; +use std::path::PathBuf; +use std::sync::mpsc::Sender; +use std::thread; + +const GRAPHITE_EXTENSION: &str = "graphite"; +const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "svg", "bmp", "gif"]; + +#[derive(Debug, Clone)] +pub enum FileIoResult { + Opened { path: PathBuf, content: Vec }, + Imported { path: PathBuf, content: Vec }, + SavedDocument { document_id: DocumentId, path: PathBuf }, + ExportComplete, + Cancelled, + Failed(String), +} + +pub fn spawn_open_dialog(sender: Sender) { + thread::spawn(move || { + let mut all_exts: Vec<&str> = Vec::with_capacity(IMAGE_EXTENSIONS.len() + 1); + all_exts.push(GRAPHITE_EXTENSION); + all_exts.extend_from_slice(IMAGE_EXTENSIONS); + + let dialog = rfd::FileDialog::new() + .set_title("Open File") + .add_filter("Graphite & Images", &all_exts) + .add_filter("Graphite Document", &[GRAPHITE_EXTENSION]) + .add_filter("Images", IMAGE_EXTENSIONS); + + let result = match dialog.pick_file() { + Some(path) => match std::fs::read(&path) { + Ok(content) => FileIoResult::Opened { path, content }, + Err(e) => FileIoResult::Failed(format!("Failed to read {}: {e}", path.display())), + }, + None => FileIoResult::Cancelled, + }; + let _ = sender.send(result); + }); +} + +pub fn spawn_import_dialog(sender: Sender) { + thread::spawn(move || { + let dialog = rfd::FileDialog::new().set_title("Import Image").add_filter("Images", IMAGE_EXTENSIONS); + + let result = match dialog.pick_file() { + Some(path) => match std::fs::read(&path) { + Ok(content) => FileIoResult::Imported { path, content }, + Err(e) => FileIoResult::Failed(format!("Failed to read {}: {e}", path.display())), + }, + None => FileIoResult::Cancelled, + }; + let _ = sender.send(result); + }); +} + +pub fn spawn_save_dialog(sender: Sender, suggested_name: String, suggested_folder: Option, content: Vec) { + thread::spawn(move || { + let result = match resolve_save_path(&suggested_name, suggested_folder.as_deref(), None) { + Some(path) => match std::fs::write(&path, &content) { + Ok(()) => FileIoResult::ExportComplete, + Err(e) => FileIoResult::Failed(format!("Failed to write {}: {e}", path.display())), + }, + None => FileIoResult::Cancelled, + }; + let _ = sender.send(result); + }); +} + +pub fn spawn_save_document(sender: Sender, document_id: DocumentId, suggested_name: String, explicit_path: Option, suggested_folder: Option, content: Vec) { + thread::spawn(move || { + let chosen = match explicit_path { + Some(path) => Some(path), + None => resolve_save_path(&suggested_name, suggested_folder.as_deref(), Some(GRAPHITE_EXTENSION)), + }; + let result = match chosen { + Some(path) => match std::fs::write(&path, &content) { + Ok(()) => FileIoResult::SavedDocument { document_id, path }, + Err(e) => FileIoResult::Failed(format!("Failed to write {}: {e}", path.display())), + }, + None => FileIoResult::Cancelled, + }; + let _ = sender.send(result); + }); +} + +pub fn spawn_export_image(sender: Sender, svg: String, name: String, mime: String, size: (f64, f64)) { + thread::spawn(move || { + let extension = match mime.as_str() { + "image/svg+xml" => "svg", + "image/png" => "png", + "image/jpeg" => "jpg", + _ => "bin", + }; + let chosen = resolve_save_path(&name, None, Some(extension)); + let Some(path) = chosen else { + let _ = sender.send(FileIoResult::Cancelled); + return; + }; + + let bytes = match mime.as_str() { + "image/svg+xml" => Ok(svg.into_bytes()), + "image/png" => rasterise_svg(&svg, size, false), + "image/jpeg" => rasterise_svg(&svg, size, true), + other => Err(format!("Unsupported export mime: {other}")), + }; + + let result = match bytes.and_then(|b| std::fs::write(&path, &b).map_err(|e| format!("Failed to write {}: {e}", path.display()))) { + Ok(()) => FileIoResult::ExportComplete, + Err(message) => FileIoResult::Failed(message), + }; + let _ = sender.send(result); + }); +} + +fn resolve_save_path(suggested_name: &str, suggested_folder: Option<&std::path::Path>, force_extension: Option<&str>) -> Option { + let mut dialog = rfd::FileDialog::new().set_title("Save File").set_file_name(suggested_name); + if let Some(folder) = suggested_folder { + dialog = dialog.set_directory(folder); + } + if let Some(ext) = force_extension { + let label = format!("{} file", ext.to_ascii_uppercase()); + dialog = dialog.add_filter(label.as_str(), &[ext]); + } + let mut path = dialog.save_file()?; + if let Some(ext) = force_extension { + if path.extension().and_then(|e| e.to_str()).map(|e| !e.eq_ignore_ascii_case(ext)).unwrap_or(true) { + path.set_extension(ext); + } + } + Some(path) +} + +fn rasterise_svg(svg: &str, size: (f64, f64), jpeg: bool) -> Result, String> { + let (width, height) = (size.0.max(1.0) as u32, size.1.max(1.0) as u32); + let opts = usvg::Options::default(); + let tree = usvg::Tree::from_str(svg, &opts).map_err(|e| format!("SVG parse error: {e}"))?; + + let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height).ok_or_else(|| "Pixmap allocation failed (zero or oversized dimensions)".to_string())?; + if jpeg { + pixmap.fill(resvg::tiny_skia::Color::WHITE); + } + + let tree_size = tree.size(); + let scale_x = width as f32 / tree_size.width(); + let scale_y = height as f32 / tree_size.height(); + let transform = resvg::tiny_skia::Transform::from_scale(scale_x, scale_y); + resvg::render(&tree, transform, &mut pixmap.as_mut()); + + if jpeg { + let rgba = pixmap.data(); + let buffer = image::RgbaImage::from_raw(width, height, rgba.to_vec()).ok_or_else(|| "RGBA buffer mismatch".to_string())?; + let rgb = image::DynamicImage::ImageRgba8(buffer).to_rgb8(); + let mut out = Vec::with_capacity((width * height * 3) as usize); + let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 92); + encoder.encode(rgb.as_raw(), width, height, image::ExtendedColorType::Rgb8).map_err(|e| format!("JPEG encode error: {e}"))?; + Ok(out) + } else { + pixmap.encode_png().map_err(|e| format!("PNG encode error: {e}")) + } +} diff --git a/frontend/iced/src/icons.rs b/frontend/iced/src/icons.rs new file mode 100644 index 00000000..b437cf53 --- /dev/null +++ b/frontend/iced/src/icons.rs @@ -0,0 +1,295 @@ +use iced_widget::image::Handle; +use include_dir::{Dir, include_dir}; +use resvg::tiny_skia; +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; + +static BRANDING: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../branding/assets"); + +const RASTER_SCALE: f32 = 2.0; + +static CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +pub fn icon_handle(name: &str) -> Option { + let (key, path, size) = lookup(name)?; + + if let Some(cached) = CACHE.lock().ok().and_then(|c| c.get(key).cloned()) { + return Some(cached); + } + + let bytes = BRANDING.get_file(path)?.contents(); + let handle = if path.ends_with(".svg") { + rasterize_svg(bytes, size)? + } else { + decode_raster(bytes)? + }; + + if let Ok(mut cache) = CACHE.lock() { + cache.insert(key, handle.clone()); + } + Some(handle) +} + +fn rasterize_svg(bytes: &[u8], size: Option) -> Option { + let opt = usvg::Options::default(); + let tree = usvg::Tree::from_data(bytes, &opt).ok()?; + let svg_size = tree.size(); + + let (width, height) = match size { + Some(s) => { + let s_f = s as f32 * RASTER_SCALE; + (s_f.max(1.0).round() as u32, s_f.max(1.0).round() as u32) + } + None => { + let w = (svg_size.width() * RASTER_SCALE).max(1.0).round() as u32; + let h = (svg_size.height() * RASTER_SCALE).max(1.0).round() as u32; + (w, h) + } + }; + + let scale_x = width as f32 / svg_size.width(); + let scale_y = height as f32 / svg_size.height(); + let transform = tiny_skia::Transform::from_scale(scale_x, scale_y); + + let mut pixmap = tiny_skia::Pixmap::new(width, height)?; + resvg::render(&tree, transform, &mut pixmap.as_mut()); + + let rgba = pixmap.take_demultiplied(); + Some(Handle::from_rgba(width, height, rgba)) +} + +fn decode_raster(bytes: &[u8]) -> Option { + let img = image::load_from_memory(bytes).ok()?.to_rgba8(); + let (w, h) = img.dimensions(); + Some(Handle::from_rgba(w, h, img.into_raw())) +} + +fn lookup(name: &str) -> Option<(&'static str, &'static str, Option)> { + for &(n, path, size) in ICONS { + if n == name { + return Some((n, path, size)); + } + } + None +} + +const ICONS: &[(&str, &str, Option)] = &[ + ("GraphiteLogotypeSolid", "graphics/graphite-logotype-solid.svg", None), + ("Add", "icon-12px-solid/add.svg", Some(12)), + ("Checkmark", "icon-12px-solid/checkmark.svg", Some(12)), + ("Clipped", "icon-12px-solid/clipped.svg", Some(12)), + ("CloseX", "icon-12px-solid/close-x.svg", Some(12)), + ("Delay", "icon-12px-solid/delay.svg", Some(12)), + ("Dot", "icon-12px-solid/dot.svg", Some(12)), + ("DotThick", "icon-12px-solid/dot-thick.svg", Some(12)), + ("DropdownArrow", "icon-12px-solid/dropdown-arrow.svg", Some(12)), + ("Edit12px", "icon-12px-solid/edit-12px.svg", Some(12)), + ("Empty12px", "icon-12px-solid/empty-12px.svg", Some(12)), + ("Failure", "icon-12px-solid/failure.svg", Some(12)), + ("FullscreenEnter", "icon-12px-solid/fullscreen-enter.svg", Some(12)), + ("FullscreenExit", "icon-12px-solid/fullscreen-exit.svg", Some(12)), + ("Grid", "icon-12px-solid/grid.svg", Some(12)), + ("GridDotted", "icon-12px-solid/grid-dotted.svg", Some(12)), + ("Info", "icon-12px-solid/info.svg", Some(12)), + ("KeyboardArrowDown", "icon-12px-solid/keyboard-arrow-down.svg", Some(12)), + ("KeyboardArrowLeft", "icon-12px-solid/keyboard-arrow-left.svg", Some(12)), + ("KeyboardArrowRight", "icon-12px-solid/keyboard-arrow-right.svg", Some(12)), + ("KeyboardArrowUp", "icon-12px-solid/keyboard-arrow-up.svg", Some(12)), + ("KeyboardBackspace", "icon-12px-solid/keyboard-backspace.svg", Some(12)), + ("KeyboardCommand", "icon-12px-solid/keyboard-command.svg", Some(12)), + ("KeyboardControl", "icon-12px-solid/keyboard-control.svg", Some(12)), + ("KeyboardEnter", "icon-12px-solid/keyboard-enter.svg", Some(12)), + ("KeyboardOption", "icon-12px-solid/keyboard-option.svg", Some(12)), + ("KeyboardShift", "icon-12px-solid/keyboard-shift.svg", Some(12)), + ("KeyboardSpace", "icon-12px-solid/keyboard-space.svg", Some(12)), + ("KeyboardTab", "icon-12px-solid/keyboard-tab.svg", Some(12)), + ("License12px", "icon-12px-solid/license-12px.svg", Some(12)), + ("Link", "icon-12px-solid/link.svg", Some(12)), + ("Overlays", "icon-12px-solid/overlays.svg", Some(12)), + ("Remove", "icon-12px-solid/remove.svg", Some(12)), + ("RenderModeNormal", "icon-12px-solid/render-mode-normal.svg", Some(12)), + ("RenderModeOutline", "icon-12px-solid/render-mode-outline.svg", Some(12)), + ("RenderModePixels", "icon-12px-solid/render-mode-pixels.svg", Some(12)), + ("RenderModeSvg", "icon-12px-solid/render-mode-svg.svg", Some(12)), + ("Snapping", "icon-12px-solid/snapping.svg", Some(12)), + ("SwapHorizontal", "icon-12px-solid/swap-horizontal.svg", Some(12)), + ("SwapVertical", "icon-12px-solid/swap-vertical.svg", Some(12)), + ("VerticalEllipsis", "icon-12px-solid/vertical-ellipsis.svg", Some(12)), + ("Warning", "icon-12px-solid/warning.svg", Some(12)), + ("WindowButtonWinClose", "icon-12px-solid/window-button-win-close.svg", Some(12)), + ("WindowButtonWinMaximize", "icon-12px-solid/window-button-win-maximize.svg", Some(12)), + ("WindowButtonWinMinimize", "icon-12px-solid/window-button-win-minimize.svg", Some(12)), + ("WindowButtonWinRestoreDown", "icon-12px-solid/window-button-win-restore-down.svg", Some(12)), + ("WorkingColors", "icon-12px-solid/working-colors.svg", Some(12)), + ("AlignBottom", "icon-16px-solid/align-bottom.svg", Some(16)), + ("AlignHorizontalCenter", "icon-16px-solid/align-horizontal-center.svg", Some(16)), + ("AlignLeft", "icon-16px-solid/align-left.svg", Some(16)), + ("AlignRight", "icon-16px-solid/align-right.svg", Some(16)), + ("AlignTop", "icon-16px-solid/align-top.svg", Some(16)), + ("AlignVerticalCenter", "icon-16px-solid/align-vertical-center.svg", Some(16)), + ("Artboard", "icon-16px-solid/artboard.svg", Some(16)), + ("BooleanDifference", "icon-16px-solid/boolean-difference.svg", Some(16)), + ("BooleanDivide", "icon-16px-solid/boolean-divide.svg", Some(16)), + ("BooleanIntersect", "icon-16px-solid/boolean-intersect.svg", Some(16)), + ("BooleanSubtractBack", "icon-16px-solid/boolean-subtract-back.svg", Some(16)), + ("BooleanSubtractFront", "icon-16px-solid/boolean-subtract-front.svg", Some(16)), + ("BooleanUnion", "icon-16px-solid/boolean-union.svg", Some(16)), + ("Bug", "icon-16px-solid/bug.svg", Some(16)), + ("CheckboxChecked", "icon-16px-solid/checkbox-checked.svg", Some(16)), + ("CheckboxUnchecked", "icon-16px-solid/checkbox-unchecked.svg", Some(16)), + ("Close", "icon-16px-solid/close.svg", Some(16)), + ("CloseAll", "icon-16px-solid/close-all.svg", Some(16)), + ("Code", "icon-16px-solid/code.svg", Some(16)), + ("Copy", "icon-16px-solid/copy.svg", Some(16)), + ("Credits", "icon-16px-solid/credits.svg", Some(16)), + ("CustomColor", "icon-16px-solid/custom-color.svg", Some(16)), + ("Cut", "icon-16px-solid/cut.svg", Some(16)), + ("DeselectAll", "icon-16px-solid/deselect-all.svg", Some(16)), + ("Edit", "icon-16px-solid/edit.svg", Some(16)), + ("Empty", "icon-16px-solid/empty.svg", Some(16)), + ("ExpandFillStroke", "icon-16px-solid/expand-fill-stroke.svg", Some(16)), + ("Eyedropper", "icon-16px-solid/eyedropper.svg", Some(16)), + ("EyeHidden", "icon-16px-solid/eye-hidden.svg", Some(16)), + ("EyeHide", "icon-16px-solid/eye-hide.svg", Some(16)), + ("EyeShow", "icon-16px-solid/eye-show.svg", Some(16)), + ("EyeVisible", "icon-16px-solid/eye-visible.svg", Some(16)), + ("File", "icon-16px-solid/file.svg", Some(16)), + ("FileExport", "icon-16px-solid/file-export.svg", Some(16)), + ("FileImport", "icon-16px-solid/file-import.svg", Some(16)), + ("FlipHorizontal", "icon-16px-solid/flip-horizontal.svg", Some(16)), + ("FlipVertical", "icon-16px-solid/flip-vertical.svg", Some(16)), + ("Folder", "icon-16px-solid/folder.svg", Some(16)), + ("FolderOpen", "icon-16px-solid/folder-open.svg", Some(16)), + ("FrameAll", "icon-16px-solid/frame-all.svg", Some(16)), + ("FrameSelected", "icon-16px-solid/frame-selected.svg", Some(16)), + ("GraphiteLogo", "icon-16px-solid/graphite-logo.svg", Some(16)), + ("GraphViewClosed", "icon-16px-solid/graph-view-closed.svg", Some(16)), + ("GraphViewOpen", "icon-16px-solid/graph-view-open.svg", Some(16)), + ("HandleVisibilityAll", "icon-16px-solid/handle-visibility-all.svg", Some(16)), + ("HandleVisibilityFrontier", "icon-16px-solid/handle-visibility-frontier.svg", Some(16)), + ("HandleVisibilitySelected", "icon-16px-solid/handle-visibility-selected.svg", Some(16)), + ("Heart", "icon-16px-solid/heart.svg", Some(16)), + ("HistoryRedo", "icon-16px-solid/history-redo.svg", Some(16)), + ("HistoryUndo", "icon-16px-solid/history-undo.svg", Some(16)), + ("IconsGrid", "icon-16px-solid/icons-grid.svg", Some(16)), + ("Image", "icon-16px-solid/image.svg", Some(16)), + ("InterpolationBlend", "icon-16px-solid/interpolation-blend.svg", Some(16)), + ("InterpolationMorph", "icon-16px-solid/interpolation-morph.svg", Some(16)), + ("Layer", "icon-16px-solid/layer.svg", Some(16)), + ("License", "icon-16px-solid/license.svg", Some(16)), + ("NewLayer", "icon-16px-solid/new-layer.svg", Some(16)), + ("Node", "icon-16px-solid/node.svg", Some(16)), + ("NodeBlur", "icon-16px-solid/node-blur.svg", Some(16)), + ("NodeBrushwork", "icon-16px-solid/node-brushwork.svg", Some(16)), + ("NodeColorCorrection", "icon-16px-solid/node-color-correction.svg", Some(16)), + ("NodeGradient", "icon-16px-solid/node-gradient.svg", Some(16)), + ("NodeMagicWand", "icon-16px-solid/node-magic-wand.svg", Some(16)), + ("NodeMask", "icon-16px-solid/node-mask.svg", Some(16)), + ("NodeMotionBlur", "icon-16px-solid/node-motion-blur.svg", Some(16)), + ("NodeNodes", "icon-16px-solid/node-nodes.svg", Some(16)), + ("NodeOutput", "icon-16px-solid/node-output.svg", Some(16)), + ("NodeShape", "icon-16px-solid/node-shape.svg", Some(16)), + ("NodeText", "icon-16px-solid/node-text.svg", Some(16)), + ("NodeTransform", "icon-16px-solid/node-transform.svg", Some(16)), + ("PadlockLocked", "icon-16px-solid/padlock-locked.svg", Some(16)), + ("PadlockUnlocked", "icon-16px-solid/padlock-unlocked.svg", Some(16)), + ("Paste", "icon-16px-solid/paste.svg", Some(16)), + ("PinActive", "icon-16px-solid/pin-active.svg", Some(16)), + ("PinInactive", "icon-16px-solid/pin-inactive.svg", Some(16)), + ("PlaybackPause", "icon-16px-solid/playback-pause.svg", Some(16)), + ("PlaybackPlay", "icon-16px-solid/playback-play.svg", Some(16)), + ("PlaybackToEnd", "icon-16px-solid/playback-to-end.svg", Some(16)), + ("PlaybackToStart", "icon-16px-solid/playback-to-start.svg", Some(16)), + ("Random", "icon-16px-solid/random.svg", Some(16)), + ("Reload", "icon-16px-solid/reload.svg", Some(16)), + ("Reset", "icon-16px-solid/reset.svg", Some(16)), + ("Resync", "icon-16px-solid/resync.svg", Some(16)), + ("Reverse", "icon-16px-solid/reverse.svg", Some(16)), + ("ReverseRadialGradientToLeft", "icon-16px-solid/reverse-radial-gradient-to-left.svg", Some(16)), + ("ReverseRadialGradientToRight", "icon-16px-solid/reverse-radial-gradient-to-right.svg", Some(16)), + ("Save", "icon-16px-solid/save.svg", Some(16)), + ("SelectAll", "icon-16px-solid/select-all.svg", Some(16)), + ("SelectParent", "icon-16px-solid/select-parent.svg", Some(16)), + ("Settings", "icon-16px-solid/settings.svg", Some(16)), + ("SmallDot", "icon-16px-solid/small-dot.svg", Some(16)), + ("Stack", "icon-16px-solid/stack.svg", Some(16)), + ("StackBottom", "icon-16px-solid/stack-bottom.svg", Some(16)), + ("StackHollow", "icon-16px-solid/stack-hollow.svg", Some(16)), + ("StackLower", "icon-16px-solid/stack-lower.svg", Some(16)), + ("StackRaise", "icon-16px-solid/stack-raise.svg", Some(16)), + ("StackReverse", "icon-16px-solid/stack-reverse.svg", Some(16)), + ("StrokeAlignCenter", "icon-16px-solid/stroke-align-center.svg", Some(16)), + ("StrokeAlignInside", "icon-16px-solid/stroke-align-inside.svg", Some(16)), + ("StrokeAlignOutside", "icon-16px-solid/stroke-align-outside.svg", Some(16)), + ("StrokeCapButt", "icon-16px-solid/stroke-cap-butt.svg", Some(16)), + ("StrokeCapRound", "icon-16px-solid/stroke-cap-round.svg", Some(16)), + ("StrokeCapSquare", "icon-16px-solid/stroke-cap-square.svg", Some(16)), + ("StrokeJoinBevel", "icon-16px-solid/stroke-join-bevel.svg", Some(16)), + ("StrokeJoinMiter", "icon-16px-solid/stroke-join-miter.svg", Some(16)), + ("StrokeJoinRound", "icon-16px-solid/stroke-join-round.svg", Some(16)), + ("StrokeOrderAbove", "icon-16px-solid/stroke-order-above.svg", Some(16)), + ("StrokeOrderBelow", "icon-16px-solid/stroke-order-below.svg", Some(16)), + ("TextAlignCenter", "icon-16px-solid/text-align-center.svg", Some(16)), + ("TextAlignLeft", "icon-16px-solid/text-align-left.svg", Some(16)), + ("TextAlignRight", "icon-16px-solid/text-align-right.svg", Some(16)), + ("TextAlignSpineAway", "icon-16px-solid/text-align-spine-away.svg", Some(16)), + ("TextAlignSpineTowards", "icon-16px-solid/text-align-spine-towards.svg", Some(16)), + ("TextJustifyAll", "icon-16px-solid/text-justify-all.svg", Some(16)), + ("TextJustifyCenter", "icon-16px-solid/text-justify-center.svg", Some(16)), + ("TextJustifyLeft", "icon-16px-solid/text-justify-left.svg", Some(16)), + ("TextJustifyRight", "icon-16px-solid/text-justify-right.svg", Some(16)), + ("Tilt", "icon-16px-solid/tilt.svg", Some(16)), + ("TiltReset", "icon-16px-solid/tilt-reset.svg", Some(16)), + ("TransformationGrab", "icon-16px-solid/transformation-grab.svg", Some(16)), + ("TransformationRotate", "icon-16px-solid/transformation-rotate.svg", Some(16)), + ("TransformationScale", "icon-16px-solid/transformation-scale.svg", Some(16)), + ("Trash", "icon-16px-solid/trash.svg", Some(16)), + ("TurnNegative90", "icon-16px-solid/turn-negative-90.svg", Some(16)), + ("TurnPositive90", "icon-16px-solid/turn-positive-90.svg", Some(16)), + ("UserManual", "icon-16px-solid/user-manual.svg", Some(16)), + ("ViewportDesignMode", "icon-16px-solid/viewport-design-mode.svg", Some(16)), + ("ViewportGuideMode", "icon-16px-solid/viewport-guide-mode.svg", Some(16)), + ("ViewportSelectMode", "icon-16px-solid/viewport-select-mode.svg", Some(16)), + ("Volunteer", "icon-16px-solid/volunteer.svg", Some(16)), + ("Website", "icon-16px-solid/website.svg", Some(16)), + ("WorkingColorsPrimary", "icon-16px-solid/working-colors-primary.svg", Some(16)), + ("WorkingColorsSecondary", "icon-16px-solid/working-colors-secondary.svg", Some(16)), + ("Zoom1x", "icon-16px-solid/zoom-1x.svg", Some(16)), + ("Zoom2x", "icon-16px-solid/zoom-2x.svg", Some(16)), + ("ZoomIn", "icon-16px-solid/zoom-in.svg", Some(16)), + ("ZoomOut", "icon-16px-solid/zoom-out.svg", Some(16)), + ("ZoomReset", "icon-16px-solid/zoom-reset.svg", Some(16)), + ("MouseHintDrag", "icon-16px-two-tone/mouse-hint-drag.svg", Some(16)), + ("MouseHintLmb", "icon-16px-two-tone/mouse-hint-lmb.svg", Some(16)), + ("MouseHintLmbDouble", "icon-16px-two-tone/mouse-hint-lmb-double.svg", Some(16)), + ("MouseHintLmbDrag", "icon-16px-two-tone/mouse-hint-lmb-drag.svg", Some(16)), + ("MouseHintMmb", "icon-16px-two-tone/mouse-hint-mmb.svg", Some(16)), + ("MouseHintMmbDrag", "icon-16px-two-tone/mouse-hint-mmb-drag.svg", Some(16)), + ("MouseHintNone", "icon-16px-two-tone/mouse-hint-none.svg", Some(16)), + ("MouseHintRmb", "icon-16px-two-tone/mouse-hint-rmb.svg", Some(16)), + ("MouseHintRmbDouble", "icon-16px-two-tone/mouse-hint-rmb-double.svg", Some(16)), + ("MouseHintRmbDrag", "icon-16px-two-tone/mouse-hint-rmb-drag.svg", Some(16)), + ("MouseHintScrollDown", "icon-16px-two-tone/mouse-hint-scroll-down.svg", Some(16)), + ("MouseHintScrollUp", "icon-16px-two-tone/mouse-hint-scroll-up.svg", Some(16)), + ("GeneralArtboardTool", "icon-24px-two-tone/general-artboard-tool.svg", Some(24)), + ("GeneralEyedropperTool", "icon-24px-two-tone/general-eyedropper-tool.svg", Some(24)), + ("GeneralFillTool", "icon-24px-two-tone/general-fill-tool.svg", Some(24)), + ("GeneralGradientTool", "icon-24px-two-tone/general-gradient-tool.svg", Some(24)), + ("GeneralNavigateTool", "icon-24px-two-tone/general-navigate-tool.svg", Some(24)), + ("GeneralSelectTool", "icon-24px-two-tone/general-select-tool.svg", Some(24)), + ("RasterBrushTool", "icon-24px-two-tone/raster-brush-tool.svg", Some(24)), + ("RasterCloneTool", "icon-24px-two-tone/raster-clone-tool.svg", Some(24)), + ("RasterDetailTool", "icon-24px-two-tone/raster-detail-tool.svg", Some(24)), + ("RasterHealTool", "icon-24px-two-tone/raster-heal-tool.svg", Some(24)), + ("RasterPatchTool", "icon-24px-two-tone/raster-patch-tool.svg", Some(24)), + ("RasterRelightTool", "icon-24px-two-tone/raster-relight-tool.svg", Some(24)), + ("VectorEllipseTool", "icon-24px-two-tone/vector-ellipse-tool.svg", Some(24)), + ("VectorFreehandTool", "icon-24px-two-tone/vector-freehand-tool.svg", Some(24)), + ("VectorLineTool", "icon-24px-two-tone/vector-line-tool.svg", Some(24)), + ("VectorPathTool", "icon-24px-two-tone/vector-path-tool.svg", Some(24)), + ("VectorPenTool", "icon-24px-two-tone/vector-pen-tool.svg", Some(24)), + ("VectorPolygonTool", "icon-24px-two-tone/vector-polygon-tool.svg", Some(24)), + ("VectorRectangleTool", "icon-24px-two-tone/vector-rectangle-tool.svg", Some(24)), + ("VectorSplineTool", "icon-24px-two-tone/vector-spline-tool.svg", Some(24)), + ("VectorTextTool", "icon-24px-two-tone/vector-text-tool.svg", Some(24)), +]; diff --git a/frontend/iced/src/main.rs b/frontend/iced/src/main.rs index f68e607f..ba4f7db3 100644 --- a/frontend/iced/src/main.rs +++ b/frontend/iced/src/main.rs @@ -1,7 +1,14 @@ mod app; +mod clipboard; +mod file_io; +mod icons; mod input; mod layout; +mod persist; +mod pointer; mod shell; +mod window_control; + mod viewport; mod viewport_widget; mod widgets; diff --git a/frontend/iced/src/persist.rs b/frontend/iced/src/persist.rs new file mode 100644 index 00000000..55bfbeca --- /dev/null +++ b/frontend/iced/src/persist.rs @@ -0,0 +1,143 @@ +use graphite_editor::messages::frontend::utility_types::PersistedState; +use graphite_editor::messages::prelude::DocumentId; +use std::path::PathBuf; + +const APP_DIRECTORY_NAME: &str = if cfg!(target_os = "linux") { "graphite" } else { "Graphite" }; +const STATE_FILE_NAME: &str = "state.json"; +const PREFERENCES_FILE_NAME: &str = "preferences.json"; +const DOCUMENTS_DIRECTORY_NAME: &str = "documents"; +const DOCUMENT_FILE_EXTENSION: &str = "graphite"; + +fn root_dir() -> Option { + let base = dirs::config_local_dir().or_else(dirs::data_local_dir)?; + let path = base.join(APP_DIRECTORY_NAME); + if let Err(e) = std::fs::create_dir_all(&path) { + tracing::warn!("failed to create graphite config directory at {path:?}: {e}"); + return None; + } + Some(path) +} + +fn documents_dir() -> Option { + let path = root_dir()?.join(DOCUMENTS_DIRECTORY_NAME); + if let Err(e) = std::fs::create_dir_all(&path) { + tracing::warn!("failed to create documents directory at {path:?}: {e}"); + return None; + } + Some(path) +} + +fn state_path() -> Option { + Some(root_dir()?.join(STATE_FILE_NAME)) +} + +fn preferences_path() -> Option { + Some(root_dir()?.join(PREFERENCES_FILE_NAME)) +} + +fn document_path(id: DocumentId) -> Option { + Some(documents_dir()?.join(format!("{:x}.{}", id.0, DOCUMENT_FILE_EXTENSION))) +} + +pub fn read_state() -> Option { + let path = state_path()?; + let raw = match std::fs::read_to_string(&path) { + Ok(raw) => raw, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None, + Err(e) => { + tracing::warn!("failed to read persisted state from {path:?}: {e}"); + return None; + } + }; + match serde_json::from_str::(&raw) { + Ok(state) => Some(state), + Err(e) => { + tracing::warn!("failed to parse persisted state at {path:?}: {e}"); + None + } + } +} + +pub fn write_state(state: &PersistedState) { + let Some(path) = state_path() else { return }; + let serialized = match serde_json::to_string_pretty(state) { + Ok(s) => s, + Err(e) => { + tracing::warn!("failed to serialize persisted state: {e}"); + return; + } + }; + if let Err(e) = std::fs::write(&path, serialized) { + tracing::warn!("failed to write persisted state to {path:?}: {e}"); + return; + } + garbage_collect_documents(state); +} + +pub fn read_document(document_id: DocumentId) -> Option { + let path = document_path(document_id)?; + match std::fs::read_to_string(&path) { + Ok(content) => Some(content), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + tracing::warn!("failed to read document {document_id:?} from {path:?}: {e}"); + None + } + } +} + +pub fn write_document(document_id: DocumentId, content: &str) { + let Some(path) = document_path(document_id) else { return }; + if let Err(e) = std::fs::write(&path, content) { + tracing::warn!("failed to write document {document_id:?} to {path:?}: {e}"); + } +} + +pub fn delete_document(document_id: DocumentId) { + let Some(path) = document_path(document_id) else { return }; + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => tracing::warn!("failed to delete document {document_id:?} at {path:?}: {e}"), + } +} + +pub fn read_preferences() -> Option { + let path = preferences_path()?; + match std::fs::read_to_string(&path) { + Ok(raw) => Some(raw), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + tracing::warn!("failed to read preferences from {path:?}: {e}"); + None + } + } +} + +pub fn write_preferences(json: &str) { + let Some(path) = preferences_path() else { return }; + if let Err(e) = std::fs::write(&path, json) { + tracing::warn!("failed to write preferences to {path:?}: {e}"); + } +} + +fn garbage_collect_documents(state: &PersistedState) { + let Some(dir) = documents_dir() else { return }; + let valid: std::collections::HashSet = state.documents.iter().filter_map(|doc| document_path(doc.id)).collect(); + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return, + Err(e) => { + tracing::warn!("failed to scan documents directory at {dir:?}: {e}"); + return; + } + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && !valid.contains(&path) { + if let Err(e) = std::fs::remove_file(&path) { + tracing::warn!("failed to remove orphaned document at {path:?}: {e}"); + } + } + } +} diff --git a/frontend/iced/src/pointer.rs b/frontend/iced/src/pointer.rs new file mode 100644 index 00000000..7306ac2c --- /dev/null +++ b/frontend/iced/src/pointer.rs @@ -0,0 +1,161 @@ +use std::time::{Duration, Instant}; + +use graphite_editor::consts::DOUBLE_CLICK_MILLISECONDS; +use graphite_editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; +use graphite_editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, MouseKeys, ScrollDelta}; + +use winit::event::{ElementState, MouseButton, MouseScrollDelta}; + +pub const DEFAULT_TOOLSHELF_WIDTH: f64 = 48.0; +pub const DEFAULT_SIDEBAR_WIDTH: f64 = 340.0; +pub const DEFAULT_MENUBAR_HEIGHT: f64 = 28.0; +pub const DEFAULT_STATUSBAR_HEIGHT: f64 = 24.0; + +const SCROLL_LINE_HEIGHT: f64 = 16.0; +const SCROLL_LINE_WIDTH: f64 = 16.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ViewportRegion { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +impl ViewportRegion { + pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self { + Self { x, y, width, height } + } + + pub fn from_window(window_width: f64, window_height: f64) -> Self { + let x = DEFAULT_TOOLSHELF_WIDTH; + let y = DEFAULT_MENUBAR_HEIGHT; + let width = (window_width - DEFAULT_TOOLSHELF_WIDTH - DEFAULT_SIDEBAR_WIDTH).max(0.0); + let height = (window_height - DEFAULT_MENUBAR_HEIGHT - DEFAULT_STATUSBAR_HEIGHT).max(0.0); + Self { x, y, width, height } + } + + pub fn contains(&self, position_logical: (f64, f64)) -> bool { + let (px, py) = position_logical; + px >= self.x && py >= self.y && px < self.x + self.width && py < self.y + self.height + } + + pub fn to_viewport_local(&self, position_logical: (f64, f64)) -> (f64, f64) { + (position_logical.0 - self.x, position_logical.1 - self.y) + } +} + +impl Default for ViewportRegion { + fn default() -> Self { + Self { x: DEFAULT_TOOLSHELF_WIDTH, y: DEFAULT_MENUBAR_HEIGHT, width: 0.0, height: 0.0 } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PointerHit { + Viewport, + Chrome, +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct EditorPointerState { + buttons: u8, + last_position_logical: (f64, f64), + last_down: Option, +} + +#[derive(Debug, Clone, Copy)] +struct LastDown { + button: MouseButton, + at: Instant, + position_logical: (f64, f64), +} + +impl EditorPointerState { + pub fn new() -> Self { + Self::default() + } + + pub fn buttons(&self) -> u8 { + self.buttons + } + + pub fn mouse_keys(&self) -> MouseKeys { + MouseKeys::from_bits_truncate(self.buttons) + } + + pub fn last_position_logical(&self) -> (f64, f64) { + self.last_position_logical + } + + pub fn record_move(&mut self, position_logical: (f64, f64)) { + self.last_position_logical = position_logical; + } + + pub fn record_button(&mut self, button: MouseButton, state: ElementState, position_logical: (f64, f64)) -> ButtonTransition { + self.last_position_logical = position_logical; + let bit = translate_winit_button_to_bit(button); + match state { + ElementState::Pressed => { + self.buttons |= bit; + let now = Instant::now(); + let double = self + .last_down + .is_some_and(|prev| prev.button == button && now.duration_since(prev.at) <= Duration::from_millis(DOUBLE_CLICK_MILLISECONDS)); + self.last_down = Some(LastDown { button, at: now, position_logical }); + if double { + ButtonTransition::DownDouble + } else { + ButtonTransition::Down + } + } + ElementState::Released => { + self.buttons &= !bit; + ButtonTransition::Up + } + } + } + + pub fn classify(&self, position_logical: (f64, f64), region: ViewportRegion) -> PointerHit { + if region.contains(position_logical) { PointerHit::Viewport } else { PointerHit::Chrome } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ButtonTransition { + Down, + DownDouble, + Up, +} + +pub fn translate_winit_button_to_bit(button: MouseButton) -> u8 { + match button { + MouseButton::Left => MouseKeys::LEFT.bits(), + MouseButton::Right => MouseKeys::RIGHT.bits(), + MouseButton::Middle => MouseKeys::MIDDLE.bits(), + MouseButton::Back => MouseKeys::BACK.bits(), + MouseButton::Forward => MouseKeys::FORWARD.bits(), + MouseButton::Other(_) => 0, + } +} + +pub fn build_editor_mouse_state(position_logical: (f64, f64), buttons: u8) -> EditorMouseState { + EditorMouseState::from_keys_and_editor_position(buttons, position_logical.into()) +} + +pub fn build_editor_mouse_state_with_scroll(position_logical: (f64, f64), buttons: u8, scroll: ScrollDelta) -> EditorMouseState { + let mut state = build_editor_mouse_state(position_logical, buttons); + state.scroll_delta = scroll; + state +} + +pub fn winit_scroll_to_delta(delta: MouseScrollDelta) -> ScrollDelta { + match delta { + MouseScrollDelta::LineDelta(x, y) => ScrollDelta::new(f64::from(x) * SCROLL_LINE_WIDTH, f64::from(y) * SCROLL_LINE_HEIGHT, 0.0), + MouseScrollDelta::PixelDelta(position) => ScrollDelta::new(position.x, position.y, 0.0), + } +} + +pub fn empty_modifiers() -> ModifierKeys { + ModifierKeys::empty() +} diff --git a/frontend/iced/src/window_control.rs b/frontend/iced/src/window_control.rs new file mode 100644 index 00000000..f37212f9 --- /dev/null +++ b/frontend/iced/src/window_control.rs @@ -0,0 +1,141 @@ +use std::sync::mpsc::{Sender, channel}; + +use graphite_editor::messages::frontend::utility_types::MouseCursorIcon; +use winit::cursor::CursorIcon; +use winit::dpi::{LogicalPosition, Position}; +use winit::monitor::Fullscreen; +use winit::window::{CursorGrabMode, Window}; + +pub enum WindowCommand { + Minimize, + Maximize, + Fullscreen, + Close, + Hide, + Focus, + StartDrag, + SetTitle(String), + SetCursorIcon(CursorIcon), + SetMouseCursor(MouseCursorIcon), + PointerLock, + PointerWarp { position: (f64, f64) }, + PointerUnlock, +} + +pub struct WindowCommandSender(pub Sender); + +impl Clone for WindowCommandSender { + fn clone(&self) -> Self { + WindowCommandSender(self.0.clone()) + } +} + +impl WindowCommandSender { + pub fn send(&self, command: WindowCommand) { + if let Err(error) = self.0.send(command) { + tracing::warn!("dropped window command: {error}"); + } + } +} + +pub fn channel_pair() -> (WindowCommandSender, std::sync::mpsc::Receiver) { + let (sender, receiver) = channel(); + (WindowCommandSender(sender), receiver) +} + +pub fn handle_command(window: &dyn Window, command: WindowCommand) { + match command { + WindowCommand::Minimize => window.set_minimized(true), + WindowCommand::Maximize => { + if window.fullscreen().is_some() { + return; + } + window.set_maximized(!window.is_maximized()); + } + WindowCommand::Fullscreen => { + if window.fullscreen().is_some() { + window.set_fullscreen(None); + } else { + window.set_fullscreen(Some(Fullscreen::Borderless(None))); + } + } + WindowCommand::Close => { + tracing::info!("window close requested"); + } + WindowCommand::Hide => window.set_visible(false), + WindowCommand::Focus => { + window.set_minimized(false); + window.focus_window(); + } + WindowCommand::StartDrag => { + if window.fullscreen().is_some() { + return; + } + if let Err(error) = window.drag_window() { + tracing::debug!("drag_window failed: {error}"); + } + } + WindowCommand::SetTitle(title) => window.set_title(&title), + WindowCommand::SetCursorIcon(icon) => { + window.set_cursor_visible(true); + window.set_cursor(icon.into()); + } + WindowCommand::SetMouseCursor(cursor) => apply_mouse_cursor(window, cursor), + WindowCommand::PointerLock => { + if window.set_cursor_grab(CursorGrabMode::Locked).is_err() { + let _ = window.set_cursor_grab(CursorGrabMode::Confined); + } + window.set_cursor_visible(false); + } + WindowCommand::PointerWarp { position } => { + let pos = Position::Logical(LogicalPosition::new(position.0, position.1)); + if let Err(error) = window.set_cursor_position(pos) { + tracing::debug!("set_cursor_position failed: {error}"); + } + } + WindowCommand::PointerUnlock => { + let _ = window.set_cursor_grab(CursorGrabMode::None); + window.set_cursor_visible(true); + } + } +} + +pub fn translate_cursor(cursor: MouseCursorIcon) -> CursorIcon { + match cursor { + MouseCursorIcon::Default => CursorIcon::Default, + MouseCursorIcon::None => CursorIcon::Default, + MouseCursorIcon::ZoomIn => CursorIcon::ZoomIn, + MouseCursorIcon::ZoomOut => CursorIcon::ZoomOut, + MouseCursorIcon::Grabbing => CursorIcon::Grabbing, + MouseCursorIcon::Crosshair => CursorIcon::Crosshair, + MouseCursorIcon::Text => CursorIcon::Text, + MouseCursorIcon::Move => CursorIcon::Move, + MouseCursorIcon::NSResize => CursorIcon::NsResize, + MouseCursorIcon::EWResize => CursorIcon::EwResize, + MouseCursorIcon::NESWResize => CursorIcon::NeswResize, + MouseCursorIcon::NWSEResize => CursorIcon::NwseResize, + MouseCursorIcon::Rotate => CursorIcon::Alias, + } +} + +fn apply_mouse_cursor(window: &dyn Window, cursor: MouseCursorIcon) { + if matches!(cursor, MouseCursorIcon::None) { + window.set_cursor_visible(false); + return; + } + window.set_cursor_visible(true); + window.set_cursor(translate_cursor(cursor).into()); +} + +pub fn format_window_title(active_doc_name: Option<&str>) -> String { + match active_doc_name { + Some(name) if !name.is_empty() => format!("{name} \u{2013} Graphite"), + _ => String::from("Graphite"), + } +} + +pub fn drain_into(receiver: &std::sync::mpsc::Receiver, mut apply: F) { + while let Ok(command) = receiver.try_recv() { + apply(command); + } +} -- 2.51.0