Compare commits

..

No commits in common. "6fcf0efd49bf84c74d3aa4581c19ce12ca3d4bcc" and "9cebfa128f08d533b2d975c8abf4af74c4399ea3" have entirely different histories.

16 changed files with 790 additions and 2938 deletions

1047
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -14,29 +14,8 @@ path = "src/main.rs"
[dependencies]
graphite-editor = { workspace = true }
graph-craft = { workspace = true }
wgpu-executor = { workspace = true }
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"
dirs = { workspace = true }
image = { workspace = true }
include_dir = { workspace = true }
iced = { version = "0.14", default-features = false, features = ["wgpu", "tokio"] }
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"

View File

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

View File

@ -1,111 +0,0 @@
use raw_window_handle::{DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle, WindowHandle};
use std::sync::Mutex;
pub struct ClipboardHandle {
inner: Mutex<window_clipboard::Clipboard>,
}
impl ClipboardHandle {
pub fn new_from_raw(window_handle: RawWindowHandle, display_handle: RawDisplayHandle) -> Option<Self> {
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<String> {
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<String> {
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<DisplayHandle<'_>, HandleError> {
Ok(unsafe { DisplayHandle::borrow_raw(self.display) })
}
}
impl HasWindowHandle for RawHandles {
fn window_handle(&self) -> Result<WindowHandle<'_>, HandleError> {
Ok(unsafe { WindowHandle::borrow_raw(self.window) })
}
}

View File

@ -1,161 +0,0 @@
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<u8> },
Imported { path: PathBuf, content: Vec<u8> },
SavedDocument { document_id: DocumentId, path: PathBuf },
ExportComplete,
Cancelled,
Failed(String),
}
pub fn spawn_open_dialog(sender: Sender<FileIoResult>) {
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<FileIoResult>) {
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<FileIoResult>, suggested_name: String, suggested_folder: Option<PathBuf>, content: Vec<u8>) {
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<FileIoResult>, document_id: DocumentId, suggested_name: String, explicit_path: Option<PathBuf>, suggested_folder: Option<PathBuf>, content: Vec<u8>) {
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<FileIoResult>, 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<PathBuf> {
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<Vec<u8>, 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}"))
}
}

View File

@ -1,295 +0,0 @@
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<Mutex<HashMap<&'static str, Handle>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
pub fn icon_handle(name: &str) -> Option<Handle> {
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<u32>) -> Option<Handle> {
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<Handle> {
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<u32>)> {
for &(n, path, size) in ICONS {
if n == name {
return Some((n, path, size));
}
}
None
}
const ICONS: &[(&str, &str, Option<u32>)] = &[
("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)),
];

View File

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

View File

@ -1,70 +0,0 @@
/// 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<LayoutTarget, Layout>,
}
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 get(&self, target: LayoutTarget) -> Option<&Layout> {
self.layouts.get(&target)
}
pub fn iter(&self) -> impl Iterator<Item = (&LayoutTarget, &Layout)> {
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;
}
}

View File

@ -1,20 +1,74 @@
mod app;
mod clipboard;
mod file_io;
mod icons;
mod input;
mod layout;
mod persist;
mod pointer;
mod shell;
mod window_control;
use graphite_editor::application::{Editor, Environment, Host, Platform};
use graphite_editor::messages::prelude::*;
use iced::widget::{column, container, text};
use iced::{Element, Length, Task, Theme};
use rand::Rng;
mod viewport;
mod viewport_widget;
mod widgets;
#[derive(Debug, Clone)]
enum Message {
Init,
}
fn main() {
struct App {
editor: Editor,
last_frontend_count: usize,
}
impl App {
fn boot() -> (Self, Task<Message>) {
let environment = Environment {
platform: Platform::Desktop,
host: detect_host(),
};
let seed = rand::rng().random();
let editor = Editor::new(environment, seed);
(Self { editor, last_frontend_count: 0 }, Task::done(Message::Init))
}
fn title(&self) -> String {
String::from("Graphite")
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Init => {
let responses = self.editor.handle_message(PortfolioMessage::Init);
self.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");
}
}
}
Task::none()
}
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()
}
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 {
tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())).init();
shell::run();
iced::application(App::boot, App::update, App::view).title(App::title).theme(App::theme).run()
}

View File

@ -1,143 +0,0 @@
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
Some(root_dir()?.join(STATE_FILE_NAME))
}
fn preferences_path() -> Option<PathBuf> {
Some(root_dir()?.join(PREFERENCES_FILE_NAME))
}
fn document_path(id: DocumentId) -> Option<PathBuf> {
Some(documents_dir()?.join(format!("{:x}.{}", id.0, DOCUMENT_FILE_EXTENSION)))
}
pub fn read_state() -> Option<PersistedState> {
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::<PersistedState>(&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<String> {
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<String> {
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<PathBuf> = 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}");
}
}
}
}

View File

@ -1,161 +0,0 @@
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<LastDown>,
}
#[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()
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,141 +0,0 @@
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<WindowCommand>);
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<WindowCommand>) {
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<F: FnMut(WindowCommand)>(receiver: &std::sync::mpsc::Receiver<WindowCommand>, mut apply: F) {
while let Ok(command) = receiver.try_recv() {
apply(command);
}
}