From fe1a03fac7e026468929f9a393ec87832a87b324 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 18 Oct 2022 22:33:27 -0700 Subject: [PATCH] Integrate Stable Diffusion with the Imaginate layer (#784) * Add AI Artist layer * WIP add a button to download the rendered folder under an AI Artist layer * Successfully download the correct image * Break out image downloading JS into helper function * Change file download from using data URLs to blob URLs * WIP rasterize to blob * Remove dimensions from AI Artist layer * Successfully draw rasterized image on layer after calculation * Working txt2img generation based on user prompt * Add img2img and the main parameters * Fix ability to rasterize multi-depth documents with blob URL images by switching them to base64 * Fix test * Rasterize with artboard background color * Allow aspect ratio stretch of AI Artist images * Add automatic resolution choosing * Add a terminate button, and make the lifecycle more robust * Add negative prompt * Add range bounds for parameter inputs * Add seed * Add tiling and restore faces * Add server status check, server hostname customization, and resizing layer to fit AI Artist resolution * Fix background color of infinite canvas rasterization * Escape prompt text sent in the JSON * Revoke blob URLs when cleared/replaced to reduce memory leak * Fix welcome screen logo color * Add PreferencesMessageHandler * Add persistent storage of preferences * Fix crash introduced in previous commit when moving mouse on page load * Add tooltips to the AI Artist layer properties * Integrate AI Artist tool into the raster section of the tool shelf * Add a refresh button to the connection status * Fix crash when generating and switching to a different document tab * Add persistent image storage to AI Artist layers and fix duplication bugs * Add a generate with random seed button * Simplify and standardize message names * Majorly improve robustness of networking code * Fix race condition causing default server hostname to show disconnected when app loads with AI Artist layer selected (probably, not confirmed fixed) * Clean up messages and function calls by changing arguments into structs * Update API to more recent server commit * Add support for picking the sampling method * Add machinery for filtering selected layers with type * Replace placeholder button icons * Improve the random icon by tilting the dice * Use selected_layers() instead of repeating that code * Fix borrow error * Change message flow in progress towards fixing #797 * Allow loading image on non-active document (fixes #797) * Reduce code duplication with rasterization * Add AI Artist tool and layer icons, and remove ugly node layer icon style * Rename "AI Artist" codename to "Imaginate" feature name Co-authored-by: otdavies Co-authored-by: 0hypercube <0hypercube@gmail.com> --- editor/build.rs | 2 +- editor/src/dispatcher.rs | 33 +- editor/src/lib.rs | 2 - editor/src/messages/dialog/dialog_message.rs | 4 + .../messages/dialog/dialog_message_handler.rs | 17 +- editor/src/messages/dialog/mod.rs | 1 + .../new_document_dialog_message_handler.rs | 1 - .../messages/dialog/preferences_dialog/mod.rs | 7 + .../preferences_dialog_message.rs | 9 + .../preferences_dialog_message_handler.rs | 115 ++++ .../src/messages/frontend/frontend_message.rs | 34 + .../src/messages/globals/global_variables.rs | 2 +- .../src/messages/globals/globals_message.rs | 2 +- .../globals/globals_message_handler.rs | 4 +- .../messages/input_mapper/default_mapping.rs | 10 +- .../input_mapper_message_handler.rs | 4 +- .../utility_types/input_keyboard.rs | 2 +- .../input_preprocessor_message_handler.rs | 4 +- .../layout/utility_types/layout_widget.rs | 15 +- .../utility_types/widgets/assist_widgets.rs | 4 +- .../utility_types/widgets/button_widgets.rs | 10 + .../utility_types/widgets/input_widgets.rs | 47 +- .../utility_types/widgets/label_widgets.rs | 15 +- editor/src/messages/message.rs | 2 + editor/src/messages/mod.rs | 1 + .../artboard/artboard_message_handler.rs | 2 +- .../portfolio/document/document_message.rs | 12 +- .../document/document_message_handler.rs | 333 ++++++++-- .../navigation/navigation_message_handler.rs | 4 +- .../overlays/overlays_message_handler.rs | 2 +- .../properties_panel_message.rs | 14 + .../properties_panel_message_handler.rs | 69 ++- .../properties_panel/utility_functions.rs | 582 +++++++++++++++++- .../properties_panel/utility_types.rs | 2 - .../document/utility_types/layer_panel.rs | 2 +- .../portfolio/document/utility_types/misc.rs | 43 +- .../menu_bar/menu_bar_message_handler.rs | 7 + editor/src/messages/portfolio/mod.rs | 1 + .../messages/portfolio/portfolio_message.rs | 44 +- .../portfolio/portfolio_message_handler.rs | 205 ++++-- .../src/messages/portfolio/utility_types.rs | 58 ++ editor/src/messages/preferences/mod.rs | 7 + .../preferences/preferences_message.rs | 13 + .../preferences_message_handler.rs | 72 +++ editor/src/messages/prelude.rs | 3 + editor/src/messages/tool/tool_message.rs | 6 + .../src/messages/tool/tool_message_handler.rs | 31 +- .../tool/tool_messages/artboard_tool.rs | 2 +- .../tool/tool_messages/ellipse_tool.rs | 2 +- .../tool/tool_messages/eyedropper_tool.rs | 2 +- .../messages/tool/tool_messages/fill_tool.rs | 2 +- .../tool/tool_messages/freehand_tool.rs | 2 +- .../tool/tool_messages/gradient_tool.rs | 2 +- .../tool/tool_messages/imaginate_tool.rs | 231 +++++++ .../messages/tool/tool_messages/line_tool.rs | 2 +- editor/src/messages/tool/tool_messages/mod.rs | 1 + .../tool/tool_messages/navigate_tool.rs | 2 +- .../messages/tool/tool_messages/path_tool.rs | 2 +- .../messages/tool/tool_messages/pen_tool.rs | 2 +- .../tool/tool_messages/rectangle_tool.rs | 2 +- .../tool/tool_messages/select_tool.rs | 46 +- .../messages/tool/tool_messages/shape_tool.rs | 2 +- .../tool/tool_messages/spline_tool.rs | 2 +- .../messages/tool/tool_messages/text_tool.rs | 14 +- editor/src/messages/tool/utility_types.rs | 151 +++-- editor/src/test_utils.rs | 2 +- .../graphics/graphite-logotype-solid.svg | 18 +- .../assets/icon-16px-solid/node-imaginate.svg | 7 + frontend/assets/icon-16px-solid/random.svg | 3 + .../assets/icon-16px-solid/regenerate.svg | 6 + frontend/assets/icon-16px-solid/reload.svg | 4 + frontend/assets/icon-16px-solid/rescale.svg | 7 + frontend/assets/icon-16px-solid/reset.svg | 4 + frontend/assets/icon-16px-solid/settings.svg | 3 + .../raster-imaginate-tool.svg | 11 + frontend/src/App.vue | 3 - .../components/floating-menus/MenuList.vue | 2 + frontend/src/components/panels/LayerTree.vue | 21 +- frontend/src/components/panels/NodeGraph.vue | 16 +- frontend/src/components/panels/Properties.vue | 6 - .../widgets/buttons/PopoverButton.vue | 3 +- .../components/widgets/buttons/TextButton.vue | 19 +- .../widgets/groups/WidgetSection.vue | 6 +- .../widgets/inputs/DropdownInput.vue | 2 + .../components/widgets/inputs/FieldInput.vue | 4 + .../components/widgets/inputs/FontInput.vue | 3 +- .../components/widgets/inputs/NumberInput.vue | 13 +- .../widgets/inputs/TextAreaInput.vue | 4 +- .../components/widgets/inputs/TextInput.vue | 4 + .../components/widgets/labels/IconLabel.vue | 16 +- .../components/widgets/labels/TextLabel.vue | 4 +- .../src/components/window/workspace/Panel.vue | 15 +- .../components/window/workspace/Workspace.vue | 10 +- frontend/src/io-managers/blob.ts | 20 - frontend/src/io-managers/panic.ts | 4 +- frontend/src/io-managers/persistence.ts | 206 ++++--- frontend/src/main.ts | 23 +- frontend/src/state-providers/portfolio.ts | 47 ++ frontend/src/utility-functions/escape.ts | 14 + frontend/src/utility-functions/files.ts | 28 + frontend/src/utility-functions/icons.ts | 21 +- frontend/src/utility-functions/imaginate.ts | 439 +++++++++++++ frontend/src/utility-functions/network.ts | 33 + .../src/utility-functions/rasterization.ts | 9 +- frontend/src/wasm-communication/messages.ts | 160 ++++- frontend/wasm/src/editor_api.rs | 81 ++- graphene/src/document.rs | 243 +++++++- graphene/src/error.rs | 1 + .../layers/{image_layer => }/base64_serde.rs | 0 graphene/src/layers/blend_mode.rs | 37 +- graphene/src/layers/folder_layer.rs | 2 +- graphene/src/layers/image_layer.rs | 44 +- graphene/src/layers/imaginate_layer.rs | 286 +++++++++ graphene/src/layers/layer_info.rs | 46 +- graphene/src/layers/mod.rs | 4 + graphene/src/layers/style/mod.rs | 24 +- graphene/src/operation.rs | 93 ++- graphene/src/response.rs | 36 +- 118 files changed, 3767 insertions(+), 678 deletions(-) create mode 100644 editor/src/messages/dialog/preferences_dialog/mod.rs create mode 100644 editor/src/messages/dialog/preferences_dialog/preferences_dialog_message.rs create mode 100644 editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs create mode 100644 editor/src/messages/portfolio/utility_types.rs create mode 100644 editor/src/messages/preferences/mod.rs create mode 100644 editor/src/messages/preferences/preferences_message.rs create mode 100644 editor/src/messages/preferences/preferences_message_handler.rs create mode 100644 editor/src/messages/tool/tool_messages/imaginate_tool.rs create mode 100644 frontend/assets/icon-16px-solid/node-imaginate.svg create mode 100644 frontend/assets/icon-16px-solid/random.svg create mode 100644 frontend/assets/icon-16px-solid/regenerate.svg create mode 100644 frontend/assets/icon-16px-solid/reload.svg create mode 100644 frontend/assets/icon-16px-solid/rescale.svg create mode 100644 frontend/assets/icon-16px-solid/reset.svg create mode 100644 frontend/assets/icon-16px-solid/settings.svg create mode 100644 frontend/assets/icon-24px-two-tone/raster-imaginate-tool.svg delete mode 100644 frontend/src/io-managers/blob.ts create mode 100644 frontend/src/utility-functions/escape.ts create mode 100644 frontend/src/utility-functions/imaginate.ts create mode 100644 frontend/src/utility-functions/network.ts rename graphene/src/layers/{image_layer => }/base64_serde.rs (100%) create mode 100644 graphene/src/layers/imaginate_layer.rs diff --git a/editor/build.rs b/editor/build.rs index 69da33b90..51bfb6025 100644 --- a/editor/build.rs +++ b/editor/build.rs @@ -7,7 +7,7 @@ fn main() { let try_git_command = |args: &[&str]| -> Option { let git_output = Command::new("git").args(args).output().ok()?; let maybe_empty = String::from_utf8(git_output.stdout).ok()?; - let command_result = (!maybe_empty.is_empty()).then(|| maybe_empty)?; + let command_result = (!maybe_empty.is_empty()).then_some(maybe_empty)?; Some(command_result) }; // Execute a Git command for its output. Return "unknown" if it fails for any of the possible reasons. diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index 52d872967..24ef4c71a 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -22,6 +22,7 @@ struct DispatcherMessageHandlers { input_preprocessor_message_handler: InputPreprocessorMessageHandler, layout_message_handler: LayoutMessageHandler, portfolio_message_handler: PortfolioMessageHandler, + preferences_message_handler: PreferencesMessageHandler, tool_message_handler: ToolMessageHandler, workspace_message_handler: WorkspaceMessageHandler, } @@ -92,14 +93,16 @@ impl Dispatcher { NoOp => {} #[remain::unsorted] Init => { + // Load persistent data from the browser database + queue.push_back(FrontendMessage::TriggerLoadAutoSaveDocuments.into()); + queue.push_back(FrontendMessage::TriggerLoadPreferences.into()); + // Display the menu bar at the top of the window - let message = MenuBarMessage::SendLayout.into(); - queue.push_back(message); + queue.push_back(MenuBarMessage::SendLayout.into()); // Load the default font let font = Font::new(DEFAULT_FONT_FAMILY.into(), DEFAULT_FONT_STYLE.into()); - let message = FrontendMessage::TriggerFontLoad { font, is_default: true }.into(); - queue.push_back(message); + queue.push_back(FrontendMessage::TriggerFontLoad { font, is_default: true }.into()); } Broadcast(message) => self.message_handlers.broadcast_message_handler.process_message(message, (), &mut queue), @@ -107,9 +110,11 @@ impl Dispatcher { self.message_handlers.debug_message_handler.process_message(message, (), &mut queue); } Dialog(message) => { - self.message_handlers - .dialog_message_handler - .process_message(message, &self.message_handlers.portfolio_message_handler, &mut queue); + self.message_handlers.dialog_message_handler.process_message( + message, + (&self.message_handlers.portfolio_message_handler, &self.message_handlers.preferences_message_handler), + &mut queue, + ); } Frontend(message) => { // Handle these messages immediately by returning early @@ -145,9 +150,14 @@ impl Dispatcher { self.message_handlers.layout_message_handler.process_message(message, action_input_mapping, &mut queue); } Portfolio(message) => { - self.message_handlers - .portfolio_message_handler - .process_message(message, &self.message_handlers.input_preprocessor_message_handler, &mut queue); + self.message_handlers.portfolio_message_handler.process_message( + message, + (&self.message_handlers.input_preprocessor_message_handler, &self.message_handlers.preferences_message_handler), + &mut queue, + ); + } + Preferences(message) => { + self.message_handlers.preferences_message_handler.process_message(message, (), &mut queue); } Tool(message) => { if let Some(document) = self.message_handlers.portfolio_message_handler.active_document() { @@ -155,8 +165,9 @@ impl Dispatcher { message, ( document, + self.message_handlers.portfolio_message_handler.active_document_id().unwrap(), &self.message_handlers.input_preprocessor_message_handler, - self.message_handlers.portfolio_message_handler.font_cache(), + &self.message_handlers.portfolio_message_handler.persistent_data, ), &mut queue, ); diff --git a/editor/src/lib.rs b/editor/src/lib.rs index def154a63..4f51d66a2 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -3,8 +3,6 @@ extern crate graphite_proc_macros; // `macro_use` puts these macros into scope for all descendant code files #[macro_use] mod macros; - -// `macro_use` puts the log macros (`error!`, `warn!`, `debug!`, `info!` and `trace!`) in scope for the crate #[macro_use] extern crate log; diff --git a/editor/src/messages/dialog/dialog_message.rs b/editor/src/messages/dialog/dialog_message.rs index cb57109be..fcf575b7c 100644 --- a/editor/src/messages/dialog/dialog_message.rs +++ b/editor/src/messages/dialog/dialog_message.rs @@ -13,6 +13,9 @@ pub enum DialogMessage { #[remain::unsorted] #[child] NewDocumentDialog(NewDocumentDialogMessage), + #[remain::unsorted] + #[child] + PreferencesDialog(PreferencesDialogMessage), // Messages CloseAllDocumentsWithConfirmation, @@ -32,4 +35,5 @@ pub enum DialogMessage { }, RequestExportDialog, RequestNewDocumentDialog, + RequestPreferencesDialog, } diff --git a/editor/src/messages/dialog/dialog_message_handler.rs b/editor/src/messages/dialog/dialog_message_handler.rs index 3a914e603..7744dc3b8 100644 --- a/editor/src/messages/dialog/dialog_message_handler.rs +++ b/editor/src/messages/dialog/dialog_message_handler.rs @@ -7,17 +7,20 @@ use crate::messages::prelude::*; pub struct DialogMessageHandler { export_dialog: ExportDialogMessageHandler, new_document_dialog: NewDocumentDialogMessageHandler, + preferences_dialog: PreferencesDialogMessageHandler, } -impl MessageHandler for DialogMessageHandler { +impl MessageHandler for DialogMessageHandler { #[remain::check] - fn process_message(&mut self, message: DialogMessage, portfolio: &PortfolioMessageHandler, responses: &mut VecDeque) { + fn process_message(&mut self, message: DialogMessage, (portfolio, preferences): (&PortfolioMessageHandler, &PreferencesMessageHandler), responses: &mut VecDeque) { #[remain::sorted] match message { #[remain::unsorted] DialogMessage::ExportDialog(message) => self.export_dialog.process_message(message, (), responses), #[remain::unsorted] DialogMessage::NewDocumentDialog(message) => self.new_document_dialog.process_message(message, (), responses), + #[remain::unsorted] + DialogMessage::PreferencesDialog(message) => self.preferences_dialog.process_message(message, preferences, responses), DialogMessage::CloseAllDocumentsWithConfirmation => { let dialog = simple_dialogs::CloseAllDocumentsDialog; @@ -97,12 +100,18 @@ impl MessageHandler for DialogMessageHa self.new_document_dialog.register_properties(responses, LayoutTarget::DialogDetails); responses.push_back(FrontendMessage::DisplayDialog { icon: "File".to_string() }.into()); } + DialogMessage::RequestPreferencesDialog => { + self.preferences_dialog = PreferencesDialogMessageHandler {}; + self.preferences_dialog.register_properties(responses, LayoutTarget::DialogDetails, preferences); + responses.push_back(FrontendMessage::DisplayDialog { icon: "Settings".to_string() }.into()); + } } } advertise_actions!(DialogMessageDiscriminant; - RequestNewDocumentDialog, - RequestExportDialog, CloseAllDocumentsWithConfirmation, + RequestExportDialog, + RequestNewDocumentDialog, + RequestPreferencesDialog, ); } diff --git a/editor/src/messages/dialog/mod.rs b/editor/src/messages/dialog/mod.rs index c1cbfca98..cc9542e63 100644 --- a/editor/src/messages/dialog/mod.rs +++ b/editor/src/messages/dialog/mod.rs @@ -10,6 +10,7 @@ mod dialog_message_handler; pub mod export_dialog; pub mod new_document_dialog; +pub mod preferences_dialog; pub mod simple_dialogs; #[doc(inline)] diff --git a/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs b/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs index 5805d5015..11058091c 100644 --- a/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs +++ b/editor/src/messages/dialog/new_document_dialog/new_document_dialog_message_handler.rs @@ -83,7 +83,6 @@ impl PropertyHolder for NewDocumentDialogMessageHandler { })), WidgetHolder::new(Widget::CheckboxInput(CheckboxInput { checked: self.infinite, - icon: "Checkmark".to_string(), on_update: WidgetCallback::new(|checkbox_input: &CheckboxInput| NewDocumentDialogMessage::Infinite(checkbox_input.checked).into()), ..Default::default() })), diff --git a/editor/src/messages/dialog/preferences_dialog/mod.rs b/editor/src/messages/dialog/preferences_dialog/mod.rs new file mode 100644 index 000000000..647e847dd --- /dev/null +++ b/editor/src/messages/dialog/preferences_dialog/mod.rs @@ -0,0 +1,7 @@ +mod preferences_dialog_message; +mod preferences_dialog_message_handler; + +#[doc(inline)] +pub use preferences_dialog_message::{PreferencesDialogMessage, PreferencesDialogMessageDiscriminant}; +#[doc(inline)] +pub use preferences_dialog_message_handler::PreferencesDialogMessageHandler; diff --git a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message.rs b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message.rs new file mode 100644 index 000000000..04fd4b59a --- /dev/null +++ b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message.rs @@ -0,0 +1,9 @@ +use crate::messages::prelude::*; + +use serde::{Deserialize, Serialize}; + +#[impl_message(Message, DialogMessage, PreferencesDialog)] +#[derive(Eq, PartialEq, Clone, Debug, Serialize, Deserialize)] +pub enum PreferencesDialogMessage { + Confirm, +} diff --git a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs new file mode 100644 index 000000000..48d40abd2 --- /dev/null +++ b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs @@ -0,0 +1,115 @@ +use crate::messages::layout::utility_types::layout_widget::{Layout, LayoutGroup, Widget, WidgetCallback, WidgetHolder, WidgetLayout}; +use crate::messages::layout::utility_types::misc::LayoutTarget; +use crate::messages::layout::utility_types::widgets::button_widgets::TextButton; +use crate::messages::layout::utility_types::widgets::input_widgets::{NumberInput, TextInput}; +use crate::messages::layout::utility_types::widgets::label_widgets::{Separator, SeparatorDirection, SeparatorType, TextLabel}; +use crate::messages::prelude::*; + +/// A dialog to allow users to customize Graphite editor options +#[derive(Debug, Clone, Default)] +pub struct PreferencesDialogMessageHandler {} + +impl MessageHandler for PreferencesDialogMessageHandler { + fn process_message(&mut self, message: PreferencesDialogMessage, preferences: &PreferencesMessageHandler, responses: &mut VecDeque) { + match message { + PreferencesDialogMessage::Confirm => {} + } + + self.register_properties(responses, LayoutTarget::DialogDetails, preferences); + } + + advertise_actions! {PreferencesDialogUpdate;} +} + +impl PreferencesDialogMessageHandler { + pub fn register_properties(&self, responses: &mut VecDeque, layout_target: LayoutTarget, preferences: &PreferencesMessageHandler) { + responses.push_back( + LayoutMessage::SendLayout { + layout: self.properties(preferences), + layout_target, + } + .into(), + ) + } + + fn properties(&self, preferences: &PreferencesMessageHandler) -> Layout { + let imaginate_server_hostname = vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Imaginate".into(), + min_width: 60, + italic: true, + ..Default::default() + })), + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Server Hostname".into(), + table_align: true, + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::TextInput(TextInput { + value: preferences.imaginate_server_hostname.clone(), + min_width: 200, + on_update: WidgetCallback::new(|text_input: &TextInput| PreferencesMessage::ImaginateServerHostname { hostname: text_input.value.clone() }.into()), + ..Default::default() + })), + ]; + + let imaginate_refresh_frequency = vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { min_width: 60, ..Default::default() })), + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Refresh Frequency".into(), + table_align: true, + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::NumberInput(NumberInput { + unit: " seconds".into(), + value: Some(preferences.imaginate_refresh_frequency), + min: Some(0.), + min_width: 200, + on_update: WidgetCallback::new(|number_input: &NumberInput| PreferencesMessage::ImaginateRefreshFrequency { seconds: number_input.value.unwrap() }.into()), + ..Default::default() + })), + ]; + + let button_widgets = vec![ + WidgetHolder::new(Widget::TextButton(TextButton { + label: "Ok".to_string(), + min_width: 96, + emphasized: true, + on_update: WidgetCallback::new(|_| { + DialogMessage::CloseDialogAndThen { + followups: vec![PreferencesDialogMessage::Confirm.into()], + } + .into() + }), + ..Default::default() + })), + WidgetHolder::new(Widget::TextButton(TextButton { + label: "Reset to Defaults".to_string(), + min_width: 96, + on_update: WidgetCallback::new(|_| PreferencesMessage::ResetToDefaults.into()), + ..Default::default() + })), + ]; + + Layout::WidgetLayout(WidgetLayout::new(vec![ + LayoutGroup::Row { + widgets: vec![WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Editor Preferences".to_string(), + bold: true, + ..Default::default() + }))], + }, + LayoutGroup::Row { widgets: imaginate_server_hostname }, + LayoutGroup::Row { widgets: imaginate_refresh_frequency }, + LayoutGroup::Row { widgets: button_widgets }, + ])) + } +} diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 042a1aac2..fc5c92e20 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -7,7 +7,9 @@ use crate::messages::prelude::*; use crate::messages::tool::utility_types::HintData; use graphene::color::Color; +use graphene::layers::imaginate_layer::{ImaginateBaseImage, ImaginateGenerationParameters}; use graphene::layers::text_layer::Font; +use graphene::LayerId; use serde::{Deserialize, Serialize}; @@ -50,6 +52,28 @@ pub enum FrontendMessage { #[serde(rename = "isDefault")] is_default: bool, }, + TriggerImaginateCheckServerStatus { + hostname: String, + }, + TriggerImaginateGenerate { + parameters: ImaginateGenerationParameters, + #[serde(rename = "baseImage")] + base_image: Option, + hostname: String, + #[serde(rename = "refreshFrequency")] + refresh_frequency: f64, + #[serde(rename = "documentId")] + document_id: u64, + #[serde(rename = "layerPath")] + layer_path: Vec, + }, + TriggerImaginateTerminate { + #[serde(rename = "documentId")] + document_id: u64, + #[serde(rename = "layerPath")] + layer_path: Vec, + hostname: String, + }, TriggerImport, TriggerIndexedDbRemoveDocument { #[serde(rename = "documentId")] @@ -60,6 +84,8 @@ pub enum FrontendMessage { details: FrontendDocumentDetails, version: String, }, + TriggerLoadAutoSaveDocuments, + TriggerLoadPreferences, TriggerOpenDocument, TriggerPaste, TriggerRasterDownload { @@ -69,6 +95,12 @@ pub enum FrontendMessage { size: (f64, f64), }, TriggerRefreshBoundsOfViewports, + TriggerRevokeBlobUrl { + url: String, + }, + TriggerSavePreferences { + preferences: PreferencesMessageHandler, + }, TriggerTextCommit, TriggerTextCopy { #[serde(rename = "copyText")] @@ -126,6 +158,8 @@ pub enum FrontendMessage { multiplier: (f64, f64), }, UpdateImageData { + #[serde(rename = "documentId")] + document_id: u64, #[serde(rename = "imageData")] image_data: Vec, }, diff --git a/editor/src/messages/globals/global_variables.rs b/editor/src/messages/globals/global_variables.rs index be16fa3cc..01e5e3dd1 100644 --- a/editor/src/messages/globals/global_variables.rs +++ b/editor/src/messages/globals/global_variables.rs @@ -1,4 +1,4 @@ -use crate::messages::portfolio::document::utility_types::misc::Platform; +use crate::messages::portfolio::utility_types::Platform; use once_cell::sync::OnceCell; diff --git a/editor/src/messages/globals/globals_message.rs b/editor/src/messages/globals/globals_message.rs index f71e88e12..8085508d6 100644 --- a/editor/src/messages/globals/globals_message.rs +++ b/editor/src/messages/globals/globals_message.rs @@ -1,4 +1,4 @@ -use crate::messages::portfolio::document::utility_types::misc::Platform; +use crate::messages::portfolio::utility_types::Platform; use crate::messages::prelude::*; use serde::{Deserialize, Serialize}; diff --git a/editor/src/messages/globals/globals_message_handler.rs b/editor/src/messages/globals/globals_message_handler.rs index 0073047a4..ebb8137dd 100644 --- a/editor/src/messages/globals/globals_message_handler.rs +++ b/editor/src/messages/globals/globals_message_handler.rs @@ -8,7 +8,9 @@ impl MessageHandler for GlobalsMessageHandler { fn process_message(&mut self, message: GlobalsMessage, _data: (), _responses: &mut VecDeque) { match message { GlobalsMessage::SetPlatform { platform } => { - GLOBAL_PLATFORM.set(platform).expect("Failed to set GLOBAL_PLATFORM"); + if GLOBAL_PLATFORM.get() != Some(&platform) { + GLOBAL_PLATFORM.set(platform).expect("Failed to set GLOBAL_PLATFORM"); + } } } } diff --git a/editor/src/messages/input_mapper/default_mapping.rs b/editor/src/messages/input_mapper/default_mapping.rs index 81d0de1cc..0770d6e84 100644 --- a/editor/src/messages/input_mapper/default_mapping.rs +++ b/editor/src/messages/input_mapper/default_mapping.rs @@ -112,6 +112,13 @@ pub fn default_mapping() -> Mapping { entry!(KeyDown(Escape); action_dispatch=RectangleToolMessage::Abort), entry!(PointerMove; refresh_keys=[Alt, Shift], action_dispatch=RectangleToolMessage::Resize { center: Alt, lock_ratio: Shift }), // + // ImaginateToolMessage + entry!(KeyDown(Lmb); action_dispatch=ImaginateToolMessage::DragStart), + entry!(KeyUp(Lmb); action_dispatch=ImaginateToolMessage::DragStop), + entry!(KeyDown(Rmb); action_dispatch=ImaginateToolMessage::Abort), + entry!(KeyDown(Escape); action_dispatch=ImaginateToolMessage::Abort), + entry!(PointerMove; refresh_keys=[Alt, Shift], action_dispatch=ImaginateToolMessage::Resize { center: Alt, lock_ratio: Shift }), + // // EllipseToolMessage entry!(KeyDown(Lmb); action_dispatch=EllipseToolMessage::DragStart), entry!(KeyUp(Lmb); action_dispatch=EllipseToolMessage::DragStop), @@ -263,9 +270,10 @@ pub fn default_mapping() -> Mapping { entry!(KeyDown(KeyV); modifiers=[Accel], action_dispatch=FrontendMessage::TriggerPaste), // // DialogMessage - entry!(KeyDown(KeyN); modifiers=[Accel], action_dispatch=DialogMessage::RequestNewDocumentDialog), entry!(KeyDown(KeyW); modifiers=[Accel, Alt], action_dispatch=DialogMessage::CloseAllDocumentsWithConfirmation), entry!(KeyDown(KeyE); modifiers=[Accel], action_dispatch=DialogMessage::RequestExportDialog), + entry!(KeyDown(KeyN); modifiers=[Accel], action_dispatch=DialogMessage::RequestNewDocumentDialog), + entry!(KeyDown(Comma); modifiers=[Accel], action_dispatch=DialogMessage::RequestPreferencesDialog), // // DebugMessage entry!(KeyDown(KeyT); modifiers=[Alt], action_dispatch=DebugMessage::ToggleTraceLogs), diff --git a/editor/src/messages/input_mapper/input_mapper_message_handler.rs b/editor/src/messages/input_mapper/input_mapper_message_handler.rs index 953501e20..3c976f7f2 100644 --- a/editor/src/messages/input_mapper/input_mapper_message_handler.rs +++ b/editor/src/messages/input_mapper/input_mapper_message_handler.rs @@ -11,9 +11,7 @@ pub struct InputMapperMessageHandler { } impl MessageHandler for InputMapperMessageHandler { - fn process_message(&mut self, message: InputMapperMessage, data: (&InputPreprocessorMessageHandler, ActionList), responses: &mut VecDeque) { - let (input, actions) = data; - + fn process_message(&mut self, message: InputMapperMessage, (input, actions): (&InputPreprocessorMessageHandler, ActionList), responses: &mut VecDeque) { if let Some(message) = self.mapping.match_input_message(message, &input.keyboard, actions) { responses.push_back(message); } diff --git a/editor/src/messages/input_mapper/utility_types/input_keyboard.rs b/editor/src/messages/input_mapper/utility_types/input_keyboard.rs index 315862025..ac92e9fb8 100644 --- a/editor/src/messages/input_mapper/utility_types/input_keyboard.rs +++ b/editor/src/messages/input_mapper/utility_types/input_keyboard.rs @@ -1,4 +1,4 @@ -use crate::messages::portfolio::document::utility_types::misc::KeyboardPlatformLayout; +use crate::messages::portfolio::utility_types::KeyboardPlatformLayout; use crate::messages::prelude::*; pub use graphene::DocumentResponse; diff --git a/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs b/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs index 44d5ec6ae..df76ebecf 100644 --- a/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs +++ b/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs @@ -1,6 +1,6 @@ use crate::messages::input_mapper::utility_types::input_keyboard::{Key, KeyStates, ModifierKeys}; use crate::messages::input_mapper::utility_types::input_mouse::{MouseKeys, MouseState, ViewportBounds}; -use crate::messages::portfolio::document::utility_types::misc::KeyboardPlatformLayout; +use crate::messages::portfolio::utility_types::KeyboardPlatformLayout; use crate::messages::prelude::*; pub use graphene::DocumentResponse; @@ -183,7 +183,7 @@ impl InputPreprocessorMessageHandler { mod test { use crate::messages::input_mapper::utility_types::input_keyboard::{Key, ModifierKeys}; use crate::messages::input_mapper::utility_types::input_mouse::EditorMouseState; - use crate::messages::portfolio::document::utility_types::misc::KeyboardPlatformLayout; + use crate::messages::portfolio::utility_types::KeyboardPlatformLayout; use crate::messages::prelude::*; #[test] diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs index 5371a8b0b..089e29b4c 100644 --- a/editor/src/messages/layout/utility_types/layout_widget.rs +++ b/editor/src/messages/layout/utility_types/layout_widget.rs @@ -63,7 +63,20 @@ impl Layout { Widget::ColorInput(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)), Widget::IconButton(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)), Widget::OptionalInput(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)), - _ => None, + Widget::DropdownInput(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)), + Widget::FontInput(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)), + Widget::NumberInput(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)), + Widget::PopoverButton(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)), + Widget::TextButton(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)), + Widget::IconLabel(_) + | Widget::InvisibleStandinInput(_) + | Widget::PivotAssist(_) + | Widget::RadioInput(_) + | Widget::Separator(_) + | Widget::SwatchPairInput(_) + | Widget::TextAreaInput(_) + | Widget::TextInput(_) + | Widget::TextLabel(_) => None, }; if let Some((tooltip, Some(tooltip_shortcut))) = &mut tooltip_shortcut { apply_shortcut_to_tooltip(tooltip_shortcut, tooltip); diff --git a/editor/src/messages/layout/utility_types/widgets/assist_widgets.rs b/editor/src/messages/layout/utility_types/widgets/assist_widgets.rs index 1eb41a90a..30a38cfdc 100644 --- a/editor/src/messages/layout/utility_types/widgets/assist_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/assist_widgets.rs @@ -1,9 +1,9 @@ +use crate::messages::layout::utility_types::layout_widget::WidgetCallback; + use derivative::*; use glam::DVec2; use serde::{Deserialize, Serialize}; -use crate::messages::layout::utility_types::layout_widget::WidgetCallback; - #[derive(Clone, Default, Derivative, Serialize, Deserialize)] #[derivative(Debug, PartialEq)] pub struct PivotAssist { diff --git a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs index be4940949..5322f7741 100644 --- a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs @@ -33,6 +33,11 @@ pub struct PopoverButton { pub header: String, pub text: String, + + pub tooltip: String, + + #[serde(skip)] + pub tooltip_shortcut: Option, } #[derive(Clone, Serialize, Deserialize, Derivative, Default)] @@ -50,6 +55,11 @@ pub struct TextButton { pub disabled: bool, + pub tooltip: String, + + #[serde(skip)] + pub tooltip_shortcut: Option, + // Callbacks #[serde(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index e0ed34f61..f80735afc 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -6,7 +6,7 @@ use graphene::color::Color; use derivative::*; use serde::{Deserialize, Serialize}; -#[derive(Clone, Default, Derivative, Serialize, Deserialize)] +#[derive(Clone, Derivative, Serialize, Deserialize)] #[derivative(Debug, PartialEq)] pub struct CheckboxInput { pub checked: bool, @@ -24,6 +24,18 @@ pub struct CheckboxInput { pub on_update: WidgetCallback, } +impl Default for CheckboxInput { + fn default() -> Self { + Self { + checked: false, + icon: "Checkmark".into(), + tooltip: Default::default(), + tooltip_shortcut: Default::default(), + on_update: Default::default(), + } + } +} + #[derive(Clone, Derivative, Serialize, Deserialize)] #[derivative(Debug, PartialEq, Default)] pub struct ColorInput { @@ -64,6 +76,11 @@ pub struct DropdownInput { pub interactive: bool, pub disabled: bool, + + pub tooltip: String, + + #[serde(skip)] + pub tooltip_shortcut: Option, // // Callbacks // `on_update` exists on the `DropdownEntryData`, not this parent `DropdownInput` @@ -109,6 +126,11 @@ pub struct FontInput { pub disabled: bool, + pub tooltip: String, + + #[serde(skip)] + pub tooltip_shortcut: Option, + // Callbacks #[serde(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] @@ -159,11 +181,15 @@ pub struct NumberInput { pub disabled: bool, - // Callbacks - #[serde(skip)] - #[derivative(Debug = "ignore", PartialEq = "ignore")] - pub on_update: WidgetCallback, + #[serde(rename = "minWidth")] + pub min_width: u32, + pub tooltip: String, + + #[serde(skip)] + pub tooltip_shortcut: Option, + + // Callbacks #[serde(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] pub increment_callback_increase: WidgetCallback, @@ -171,6 +197,10 @@ pub struct NumberInput { #[serde(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] pub increment_callback_decrease: WidgetCallback, + + #[serde(skip)] + #[derivative(Debug = "ignore", PartialEq = "ignore")] + pub on_update: WidgetCallback, } #[derive(Clone, Serialize, Deserialize, Debug, Default, PartialEq, Eq)] @@ -246,6 +276,8 @@ pub struct TextAreaInput { pub disabled: bool, + pub tooltip: String, + // Callbacks #[serde(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] @@ -261,6 +293,11 @@ pub struct TextInput { pub disabled: bool, + pub tooltip: String, + + #[serde(rename = "minWidth")] + pub min_width: u32, + // Callbacks #[serde(skip)] #[derivative(Debug = "ignore", PartialEq = "ignore")] diff --git a/editor/src/messages/layout/utility_types/widgets/label_widgets.rs b/editor/src/messages/layout/utility_types/widgets/label_widgets.rs index 1086feffe..da08fa9b5 100644 --- a/editor/src/messages/layout/utility_types/widgets/label_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/label_widgets.rs @@ -5,15 +5,7 @@ use serde::{Deserialize, Serialize}; pub struct IconLabel { pub icon: String, - #[serde(rename = "iconStyle")] - pub icon_style: IconStyle, -} - -#[derive(Clone, Serialize, Deserialize, Derivative, Debug, Default, PartialEq, Eq)] -pub enum IconStyle { - #[default] - Normal, - Node, + pub tooltip: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -49,6 +41,11 @@ pub struct TextLabel { pub multiline: bool, + #[serde(rename = "minWidth")] + pub min_width: u32, + + pub tooltip: String, + // Body pub value: String, } diff --git a/editor/src/messages/message.rs b/editor/src/messages/message.rs index 76e0358f1..2634e1ab2 100644 --- a/editor/src/messages/message.rs +++ b/editor/src/messages/message.rs @@ -34,6 +34,8 @@ pub enum Message { #[child] Portfolio(PortfolioMessage), #[child] + Preferences(PreferencesMessage), + #[child] Tool(ToolMessage), #[child] Workspace(WorkspaceMessage), diff --git a/editor/src/messages/mod.rs b/editor/src/messages/mod.rs index 01157db3a..c6f3c23d2 100644 --- a/editor/src/messages/mod.rs +++ b/editor/src/messages/mod.rs @@ -10,6 +10,7 @@ pub mod input_preprocessor; pub mod layout; pub mod message; pub mod portfolio; +pub mod preferences; pub mod prelude; pub mod tool; pub mod workspace; diff --git a/editor/src/messages/portfolio/document/artboard/artboard_message_handler.rs b/editor/src/messages/portfolio/document/artboard/artboard_message_handler.rs index bff07baa8..018af1bff 100644 --- a/editor/src/messages/portfolio/document/artboard/artboard_message_handler.rs +++ b/editor/src/messages/portfolio/document/artboard/artboard_message_handler.rs @@ -85,7 +85,7 @@ impl MessageHandler for ArtboardMessageHandler { .into(), ) } else { - let render_data = RenderData::new(ViewMode::Normal, font_cache, None, false); + let render_data = RenderData::new(ViewMode::Normal, font_cache, None); responses.push_back( FrontendMessage::UpdateDocumentArtboards { svg: self.artboards_graphene_document.render_root(render_data), diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index a1391c429..6f8c4bb36 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -8,7 +8,6 @@ use graphene::layers::blend_mode::BlendMode; use graphene::layers::style::ViewMode; use graphene::LayerId; use graphene::Operation as DocumentOperation; - use serde::{Deserialize, Serialize}; #[remain::sorted] @@ -75,6 +74,9 @@ pub enum DocumentMessage { affected_folder_path: Vec, }, GroupSelectedLayers, + ImaginateClear, + ImaginateGenerate, + ImaginateTerminate, LayerChanged { affected_layer_path: Vec, }, @@ -120,6 +122,12 @@ pub enum DocumentMessage { SetBlendModeForSelectedLayers { blend_mode: BlendMode, }, + SetImageBlobUrl { + layer_path: Vec, + blob_url: String, + resolution: (f64, f64), + document_id: u64, + }, SetLayerExpansion { layer_path: Vec, set_expanded: bool, @@ -140,7 +148,7 @@ pub enum DocumentMessage { SetSnapping { snap: bool, }, - SetTexboxEditability { + SetTextboxEditability { path: Vec, editable: bool, }, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index d68232ae6..c979e1ab4 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1,6 +1,8 @@ use super::utility_types::error::EditorError; +use super::utility_types::misc::DocumentRenderMode; use crate::application::generate_uuid; use crate::consts::{ASYMPTOTIC_EFFECT, DEFAULT_DOCUMENT_NAME, FILE_SAVE_SUFFIX, GRAPHITE_DOCUMENT_VERSION, SCALE_EFFECT, SCROLLBAR_SPACING, VIEWPORT_ZOOM_TO_FIT_PADDING_SCALE_FACTOR}; +use crate::messages::frontend::utility_types::ExportBounds; use crate::messages::frontend::utility_types::{FileType, FrontendImageData}; use crate::messages::input_mapper::utility_types::macros::action_keys; use crate::messages::layout::utility_types::layout_widget::{Layout, LayoutGroup, Widget, WidgetCallback, WidgetHolder, WidgetLayout}; @@ -14,12 +16,14 @@ use crate::messages::portfolio::document::utility_types::layer_panel::{LayerMeta use crate::messages::portfolio::document::utility_types::misc::DocumentMode; use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, DocumentSave, FlipAxis}; use crate::messages::portfolio::document::utility_types::vectorize_layer_metadata; +use crate::messages::portfolio::utility_types::PersistentData; use crate::messages::prelude::*; use graphene::color::Color; -use graphene::document::Document as GrapheneDocument; +use graphene::document::{pick_layer_safe_imaginate_resolution, Document as GrapheneDocument}; use graphene::layers::blend_mode::BlendMode; use graphene::layers::folder_layer::FolderLayer; +use graphene::layers::imaginate_layer::{ImaginateBaseImage, ImaginateGenerationParameters, ImaginateStatus}; use graphene::layers::layer_info::{LayerDataType, LayerDataTypeDiscriminant}; use graphene::layers::style::{Fill, RenderData, ViewMode}; use graphene::layers::text_layer::{Font, FontCache}; @@ -87,16 +91,21 @@ impl Default for DocumentMessageHandler { } } -impl MessageHandler for DocumentMessageHandler { +impl MessageHandler for DocumentMessageHandler { #[remain::check] - fn process_message(&mut self, message: DocumentMessage, (ipp, font_cache): (&InputPreprocessorMessageHandler, &FontCache), responses: &mut VecDeque) { + fn process_message( + &mut self, + message: DocumentMessage, + (document_id, ipp, persistent_data, preferences): (u64, &InputPreprocessorMessageHandler, &PersistentData, &PreferencesMessageHandler), + responses: &mut VecDeque, + ) { use DocumentMessage::*; #[remain::sorted] match message { // Sub-messages #[remain::unsorted] - DispatchOperation(op) => match self.graphene_document.handle_operation(*op, font_cache) { + DispatchOperation(op) => match self.graphene_document.handle_operation(*op, &persistent_data.font_cache) { Ok(Some(document_responses)) => { for response in document_responses { match &response { @@ -130,7 +139,7 @@ impl MessageHandler { - self.artboard_message_handler.process_message(message, font_cache, responses); + self.artboard_message_handler.process_message(message, &persistent_data.font_cache, responses); } #[remain::unsorted] Navigation(message) => { @@ -138,25 +147,23 @@ impl MessageHandler { - self.overlays_message_handler.process_message(message, (self.overlays_visible, font_cache, ipp), responses); + self.overlays_message_handler + .process_message(message, (self.overlays_visible, &persistent_data.font_cache, ipp), responses); } #[remain::unsorted] TransformLayer(message) => { self.transform_layer_handler - .process_message(message, (&mut self.layer_metadata, &mut self.graphene_document, ipp, font_cache), responses); + .process_message(message, (&mut self.layer_metadata, &mut self.graphene_document, ipp, &persistent_data.font_cache), responses); } #[remain::unsorted] PropertiesPanel(message) => { - self.properties_panel_message_handler.process_message( - message, - PropertiesPanelMessageHandlerData { - artwork_document: &self.graphene_document, - artboard_document: &self.artboard_message_handler.artboards_graphene_document, - selected_layers: &mut self.layer_metadata.iter().filter_map(|(path, data)| data.selected.then(|| path.as_slice())), - font_cache, - }, - responses, - ); + let properties_panel_message_handler_data = PropertiesPanelMessageHandlerData { + artwork_document: &self.graphene_document, + artboard_document: &self.artboard_message_handler.artboards_graphene_document, + selected_layers: &mut self.layer_metadata.iter().filter_map(|(path, data)| data.selected.then_some(path.as_slice())), + }; + self.properties_panel_message_handler + .process_message(message, (persistent_data, properties_panel_message_handler_data), responses); } // Messages @@ -166,20 +173,20 @@ impl MessageHandler { for layer_path in &additional_layers { - responses.extend(self.select_layer(layer_path, font_cache)); + responses.extend(self.select_layer(layer_path, &persistent_data.font_cache)); } // TODO: Correctly update layer panel in clear_selection instead of here responses.push_back(FolderChanged { affected_folder_path: vec![] }.into()); responses.push_back(BroadcastEvent::SelectionChanged.into()); - self.update_layer_tree_options_bar_widgets(responses, font_cache); + self.update_layer_tree_options_bar_widgets(responses, &persistent_data.font_cache); } AlignSelectedLayers { axis, aggregate } => { self.backup(responses); let (paths, boxes): (Vec<_>, Vec<_>) = self .selected_layers() - .filter_map(|path| self.graphene_document.viewport_bounding_box(path, font_cache).ok()?.map(|b| (path, b))) + .filter_map(|path| self.graphene_document.viewport_bounding_box(path, &persistent_data.font_cache).ok()?.map(|b| (path, b))) .unzip(); let axis = match axis { @@ -187,7 +194,7 @@ impl MessageHandler DVec2::Y, }; let lerp = |bbox: &[DVec2; 2]| bbox[0].lerp(bbox[1], 0.5); - if let Some(combined_box) = self.graphene_document.combined_viewport_bounding_box(self.selected_layers(), font_cache) { + if let Some(combined_box) = self.graphene_document.combined_viewport_bounding_box(self.selected_layers(), &persistent_data.font_cache) { let aggregated = match aggregate { AlignAggregate::Min => combined_box[0], AlignAggregate::Max => combined_box[1], @@ -308,21 +315,21 @@ impl MessageHandler { - // Allows the user's transform to be restored - let old_transform = self.graphene_document.root.transform; - // Reset the root's transform (required to avoid any rotation by the user) - self.graphene_document.root.transform = DAffine2::IDENTITY; - GrapheneDocument::mark_children_as_dirty(&mut self.graphene_document.root); - // Calculate the bounding box of the region to be exported - use crate::messages::frontend::utility_types::ExportBounds; - let bbox = match bounds { - ExportBounds::AllArtwork => self.all_layer_bounds(font_cache), - ExportBounds::Selection => self.selected_visible_layers_bounding_box(font_cache), - ExportBounds::Artboard(id) => self.artboard_message_handler.artboards_graphene_document.layer(&[id]).ok().and_then(|layer| layer.aabb(font_cache)), + let bounds = match bounds { + ExportBounds::AllArtwork => self.all_layer_bounds(&persistent_data.font_cache), + ExportBounds::Selection => self.selected_visible_layers_bounding_box(&persistent_data.font_cache), + ExportBounds::Artboard(id) => self + .artboard_message_handler + .artboards_graphene_document + .layer(&[id]) + .ok() + .and_then(|layer| layer.aabb(&persistent_data.font_cache)), } .unwrap_or_default(); - let size = bbox[1] - bbox[0]; + let size = bounds[1] - bounds[0]; + + let document = self.render_document(bounds, persistent_data, DocumentRenderMode::Root); let file_suffix = &format!(".{file_type:?}").to_lowercase(); let name = match file_name.ends_with(FILE_SAVE_SUFFIX) { @@ -330,16 +337,6 @@ impl MessageHandler file_name + file_suffix, }; - let render_data = RenderData::new(ViewMode::Normal, font_cache, None, true); - let rendered = self.graphene_document.render_root(render_data); - let document = format!( - r#"{}{}"#, - bbox[0].x, bbox[0].y, size.x, size.y, size.x, size.y, "\n", rendered - ); - - self.graphene_document.root.transform = old_transform; - GrapheneDocument::mark_children_as_dirty(&mut self.graphene_document.root); - if file_type == FileType::Svg { responses.push_back(FrontendMessage::TriggerFileDownload { document, name }.into()); } else { @@ -354,7 +351,7 @@ impl MessageHandler DVec2::new(-1., 1.), FlipAxis::Y => DVec2::new(1., -1.), }; - if let Some([min, max]) = self.graphene_document.combined_viewport_bounding_box(self.selected_layers(), font_cache) { + if let Some([min, max]) = self.graphene_document.combined_viewport_bounding_box(self.selected_layers(), &persistent_data.font_cache) { let center = (max + min) / 2.; let bbox_trans = DAffine2::from_translation(-center); for path in self.selected_layers() { @@ -403,12 +400,73 @@ impl MessageHandler { + let mut selected_imaginate_layers = self.selected_layers_with_type(LayerDataTypeDiscriminant::Imaginate); + // Get what is hopefully the only selected Imaginate layer + let layer_path = selected_imaginate_layers.next(); + // Abort if we didn't have any Imaginate layer, or if there are additional ones also selected + if layer_path.is_none() || selected_imaginate_layers.next().is_some() { + return; + } + let layer_path = layer_path.unwrap(); + + let layer = self.graphene_document.layer(layer_path).expect("Clearing Imaginate image for invalid layer"); + let previous_blob_url = &layer.as_imaginate().unwrap().blob_url; + + if let Some(url) = previous_blob_url { + responses.push_back(FrontendMessage::TriggerRevokeBlobUrl { url: url.clone() }.into()); + } + responses.push_back(DocumentOperation::ImaginateClear { path: layer_path.into() }.into()); + } + ImaginateGenerate => { + if let Some(message) = self.call_imaginate(document_id, preferences, persistent_data) { + // TODO: Eventually remove this after a message system ordering architectural change + // This message is a workaround for the fact that, when `imaginate.ts` calls... + // `editor.instance.setImaginateGeneratingStatus(layerPath, 0, true);` + // ...execution transfers from the Rust part of the call stack into the JS part of the call stack (before the Rust message queue is empty, + // and there is a Properties panel refresh queued next). Then the JS calls that line shown above and enters the Rust part of the callstack + // again, so it's gone through JS (user initiation) -> Rust (process the button press) -> JS (beginning server request) -> Rust (set + // progress percentage to 0). As that call stack returns back from the Rust and back from the JS, it returns to the Rust and finishes + // processing the queue. That's where it then processes the Properties panel refresh that sent the "Ready" or "Done" state that existed + // before pressing the Generate button causing it to show "0%". So "Ready" or "Done" immediately overwrites the "0%". This block below, + // therefore, adds a redundant call to set it to 0% progress so the message execution order ends with this as the final percentage shown + // to the user. + responses.push_back( + DocumentOperation::ImaginateSetGeneratingStatus { + path: self.selected_layers_with_type(LayerDataTypeDiscriminant::Imaginate).next().unwrap().to_vec(), + percent: Some(0.), + status: ImaginateStatus::Beginning, + } + .into(), + ); + + responses.push_back(message); + } + } + ImaginateTerminate => { + let hostname = preferences.imaginate_server_hostname.clone(); + + let layer_path = { + let mut selected_imaginate_layers = self.selected_layers_with_type(LayerDataTypeDiscriminant::Imaginate); + + // Get what is hopefully the only selected Imaginate layer + match selected_imaginate_layers.next() { + // Continue only if there are no additional Imaginate layers also selected + Some(layer_path) if selected_imaginate_layers.next().is_none() => Some(layer_path.to_owned()), + _ => None, + } + }; + + if let Some(layer_path) = layer_path { + responses.push_back(FrontendMessage::TriggerImaginateTerminate { document_id, layer_path, hostname }.into()); + } + } LayerChanged { affected_layer_path } => { - if let Ok(layer_entry) = self.layer_panel_entry(affected_layer_path.clone(), font_cache) { + if let Ok(layer_entry) = self.layer_panel_entry(affected_layer_path.clone(), &persistent_data.font_cache) { responses.push_back(FrontendMessage::UpdateDocumentLayerDetails { data: layer_entry }.into()); } responses.push_back(PropertiesPanelMessage::CheckSelectedWasUpdated { path: affected_layer_path }.into()); - self.update_layer_tree_options_bar_widgets(responses, font_cache); + self.update_layer_tree_options_bar_widgets(responses, &persistent_data.font_cache); } MoveSelectedLayersTo { folder_path, @@ -466,6 +524,7 @@ impl MessageHandler responses.push_back(DocumentOperation::RenameLayer { layer_path, new_name }.into()), RenderDocument => { - let render_data = RenderData::new(self.view_mode, font_cache, Some(ipp.document_bounds()), false); + let render_data = RenderData::new(self.view_mode, &persistent_data.font_cache, Some(ipp.document_bounds())); responses.push_back( FrontendMessage::UpdateDocumentArtwork { svg: self.graphene_document.render_root(render_data), @@ -503,7 +562,7 @@ impl MessageHandler { self.backup(responses); - for path in self.layer_metadata.iter().filter_map(|(path, data)| data.selected.then(|| path.clone())) { - responses.push_back(DocumentOperation::SetLayerBlendMode { path, blend_mode }.into()); + for path in self.selected_layers() { + responses.push_back(DocumentOperation::SetLayerBlendMode { path: path.to_vec(), blend_mode }.into()); } } + SetImageBlobUrl { + layer_path, + blob_url, + resolution, + document_id, + } => { + let layer = self.graphene_document.layer(&layer_path).expect("Setting blob URL for invalid layer"); + + // Revoke the old blob URL + match &layer.data { + LayerDataType::Imaginate(imaginate) => { + if let Some(url) = &imaginate.blob_url { + responses.push_back(FrontendMessage::TriggerRevokeBlobUrl { url: url.clone() }.into()); + } + } + LayerDataType::Image(_) => {} + other => panic!("Setting blob URL for invalid layer type, which must be an `Imaginate` or `Image`. Found: `{:?}`", other), + } + + responses.push_back( + PortfolioMessage::DocumentPassMessage { + document_id, + message: DocumentOperation::SetLayerBlobUrl { layer_path, blob_url, resolution }.into(), + } + .into(), + ); + } SetLayerExpansion { layer_path, set_expanded } => { self.layer_metadata_mut(&layer_path).expanded = set_expanded; responses.push_back(DocumentStructureChanged.into()); responses.push_back(LayerChanged { affected_layer_path: layer_path }.into()) } SetLayerName { layer_path, name } => { - if let Some(layer) = self.layer_panel_entry_from_path(&layer_path, font_cache) { + if let Some(layer) = self.layer_panel_entry_from_path(&layer_path, &persistent_data.font_cache) { // Only save the history state if the name actually changed to something different if layer.name != name { self.backup(responses); @@ -664,7 +750,7 @@ impl MessageHandler { self.snapping_enabled = snap; } - SetTexboxEditability { path, editable } => { + SetTextboxEditability { path, editable } => { let text = self.graphene_document.layer(&path).unwrap().as_text().unwrap(); responses.push_back(DocumentOperation::SetTextEditability { path, editable }.into()); if editable { @@ -760,7 +846,7 @@ impl MessageHandler { - if let Some(bounds) = self.document_bounds(font_cache) { + if let Some(bounds) = self.document_bounds(&persistent_data.font_cache) { responses.push_back( NavigationMessage::FitViewportToBounds { bounds, @@ -812,6 +898,99 @@ impl MessageHandler Option { + let layer_path = { + let mut selected_imaginate_layers = self.selected_layers_with_type(LayerDataTypeDiscriminant::Imaginate); + + // Get what is hopefully the only selected Imaginate layer + match selected_imaginate_layers.next() { + // Continue only if there are no additional Imaginate layers also selected + Some(layer_path) if selected_imaginate_layers.next().is_none() => layer_path.to_owned(), + _ => return None, + } + }; + + // Prepare the Imaginate parameters and base image + + let layer = self.graphene_document.layer(&layer_path).unwrap(); + let imaginate_layer = layer.as_imaginate().unwrap(); + + let parameters = ImaginateGenerationParameters { + seed: imaginate_layer.seed, + samples: imaginate_layer.samples, + sampling_method: imaginate_layer.sampling_method.api_value().to_string(), + denoising_strength: imaginate_layer.use_img2img.then_some(imaginate_layer.denoising_strength), + cfg_scale: imaginate_layer.cfg_scale, + prompt: imaginate_layer.prompt.clone(), + negative_prompt: imaginate_layer.negative_prompt.clone(), + resolution: pick_layer_safe_imaginate_resolution(layer, &persistent_data.font_cache), + restore_faces: imaginate_layer.restore_faces, + tiling: imaginate_layer.tiling, + }; + let base_image = if imaginate_layer.use_img2img { + // Calculate the bounding box of the region to be exported + let bounds = layer.aabb(&persistent_data.font_cache).unwrap_or_default(); + let size = bounds[1] - bounds[0]; + + let svg = self.render_document(bounds, persistent_data, DocumentRenderMode::OnlyBelowLayerInFolder(&layer_path)); + + Some(ImaginateBaseImage { svg, size }) + } else { + None + }; + + Some( + FrontendMessage::TriggerImaginateGenerate { + parameters, + base_image, + hostname: preferences.imaginate_server_hostname.clone(), + refresh_frequency: preferences.imaginate_refresh_frequency, + document_id, + layer_path, + } + .into(), + ) + } + + pub fn render_document(&mut self, bounds: [DVec2; 2], persistent_data: &PersistentData, render_mode: DocumentRenderMode) -> String { + // Remove the artwork and artboard pan/tilt/zoom to render it without the user's viewport navigation, and save it to be restored at the end + + let old_artwork_transform = self.graphene_document.root.transform; + self.graphene_document.root.transform = DAffine2::IDENTITY; + GrapheneDocument::mark_children_as_dirty(&mut self.graphene_document.root); + + let old_artboard_transform = self.artboard_message_handler.artboards_graphene_document.root.transform; + self.artboard_message_handler.artboards_graphene_document.root.transform = DAffine2::IDENTITY; + GrapheneDocument::mark_children_as_dirty(&mut self.artboard_message_handler.artboards_graphene_document.root); + + // Render the document SVG code + + let size = bounds[1] - bounds[0]; + let render_data = RenderData::new(ViewMode::Normal, &persistent_data.font_cache, None); + + let artwork = match render_mode { + DocumentRenderMode::Root => self.graphene_document.render_root(render_data), + DocumentRenderMode::OnlyBelowLayerInFolder(below_layer_path) => self.graphene_document.render_layers_below(below_layer_path, render_data).unwrap(), + }; + let artboards = self.artboard_message_handler.artboards_graphene_document.render_root(render_data); + let outside_artboards_color = if self.artboard_message_handler.artboard_ids.is_empty() { "#ffffff" } else { "#000000" }; + let outside_artboards = format!(r#""#, bounds[0].x, bounds[0].y, outside_artboards_color); + let svg = format!( + r#"{}{}{}{}"#, + bounds[0].x, bounds[0].y, size.x, size.y, size.x, size.y, "\n", outside_artboards, artboards, artwork + ); + + // Transform the artwork and artboard back to their original scales + + self.graphene_document.root.transform = old_artwork_transform; + GrapheneDocument::mark_children_as_dirty(&mut self.graphene_document.root); + + self.artboard_message_handler.artboards_graphene_document.root.transform = old_artboard_transform; + GrapheneDocument::mark_children_as_dirty(&mut self.artboard_message_handler.artboards_graphene_document.root); + + svg + } + pub fn serialize_document(&self) -> String { let val = serde_json::to_string(self); // We fully expect the serialization to succeed @@ -881,11 +1060,20 @@ impl DocumentMessageHandler { } pub fn selected_layers(&self) -> impl Iterator { - self.layer_metadata.iter().filter_map(|(path, data)| data.selected.then(|| path.as_slice())) + self.layer_metadata.iter().filter_map(|(path, data)| data.selected.then_some(path.as_slice())) + } + + pub fn selected_layers_with_type(&self, discriminant: LayerDataTypeDiscriminant) -> impl Iterator { + self.selected_layers().filter(move |path| { + self.graphene_document + .layer(path) + .map(|layer| LayerDataTypeDiscriminant::from(&layer.data) == discriminant) + .unwrap_or(false) + }) } pub fn non_selected_layers(&self) -> impl Iterator { - self.layer_metadata.iter().filter_map(|(path, data)| (!data.selected).then(|| path.as_slice())) + self.layer_metadata.iter().filter_map(|(path, data)| (!data.selected).then_some(path.as_slice())) } pub fn selected_layers_without_children(&self) -> Vec<&[LayerId]> { @@ -1014,7 +1202,7 @@ impl DocumentMessageHandler { /// Returns an unsorted list of all layer paths including folders at all levels, except the document's top-level root folder itself pub fn all_layers(&self) -> impl Iterator { - self.layer_metadata.keys().filter_map(|path| (!path.is_empty()).then(|| path.as_slice())) + self.layer_metadata.keys().filter_map(|path| (!path.is_empty()).then_some(path.as_slice())) } /// Returns the paths to all layers in order @@ -1022,7 +1210,7 @@ impl DocumentMessageHandler { // Compute the indices for each layer to be able to sort them let mut layers_with_indices: Vec<(&[LayerId], Vec)> = paths // 'path.len() > 0' filters out root layer since it has no indices - .filter_map(|path| (!path.is_empty()).then(|| path)) + .filter_map(|path| (!path.is_empty()).then_some(path)) .filter_map(|path| { // TODO: `indices_for_path` can return an error. We currently skip these layers and log a warning. Once this problem is solved this code can be simplified. match self.graphene_document.indices_for_path(path) { @@ -1093,7 +1281,7 @@ impl DocumentMessageHandler { Some((document, layer_metadata)) => { // Update the currently displayed layer on the Properties panel if the selection changes after an undo action // Also appropriately update the Properties panel if an undo action results in a layer being deleted - let prev_selected_paths: Vec> = layer_metadata.iter().filter_map(|(layer_id, metadata)| metadata.selected.then(|| layer_id.clone())).collect(); + let prev_selected_paths: Vec> = layer_metadata.iter().filter_map(|(layer_id, metadata)| metadata.selected.then_some(layer_id.clone())).collect(); if prev_selected_paths != selected_paths { responses.push_back(BroadcastEvent::SelectionChanged.into()); @@ -1123,7 +1311,7 @@ impl DocumentMessageHandler { Some((document, layer_metadata)) => { // Update currently displayed layer on property panel if selection changes after redo action // Also appropriately update property panel if redo action results in a layer being added - let next_selected_paths: Vec> = layer_metadata.iter().filter_map(|(layer_id, metadata)| metadata.selected.then(|| layer_id.clone())).collect(); + let next_selected_paths: Vec> = layer_metadata.iter().filter_map(|(layer_id, metadata)| metadata.selected.then_some(layer_id.clone())).collect(); if next_selected_paths != selected_paths { responses.push_back(BroadcastEvent::SelectionChanged.into()); @@ -1231,24 +1419,33 @@ impl DocumentMessageHandler { } /// Loads layer resources such as creating the blob URLs for the images and loading all of the fonts in the document - pub fn load_layer_resources(&self, responses: &mut VecDeque, root: &LayerDataType, mut path: Vec) { + pub fn load_layer_resources(&self, responses: &mut VecDeque, root: &LayerDataType, mut path: Vec, document_id: u64) { fn walk_layers(data: &LayerDataType, path: &mut Vec, image_data: &mut Vec, fonts: &mut HashSet) { match data { - LayerDataType::Folder(f) => { - for (id, layer) in f.layer_ids.iter().zip(f.layers().iter()) { + LayerDataType::Folder(folder) => { + for (id, layer) in folder.layer_ids.iter().zip(folder.layers().iter()) { path.push(*id); walk_layers(&layer.data, path, image_data, fonts); path.pop(); } } - LayerDataType::Text(txt) => { - fonts.insert(txt.font.clone()); + LayerDataType::Text(text) => { + fonts.insert(text.font.clone()); } - LayerDataType::Image(img) => image_data.push(FrontendImageData { + LayerDataType::Image(image) => image_data.push(FrontendImageData { path: path.clone(), - image_data: img.image_data.clone(), - mime: img.mime.clone(), + image_data: image.image_data.clone(), + mime: image.mime.clone(), }), + LayerDataType::Imaginate(imaginate) => { + if let Some(data) = &imaginate.image_data { + image_data.push(FrontendImageData { + path: path.clone(), + image_data: data.image_data.clone(), + mime: imaginate.mime.clone(), + }); + } + } _ => {} } } @@ -1257,7 +1454,7 @@ impl DocumentMessageHandler { let mut fonts = HashSet::new(); walk_layers(root, &mut path, &mut image_data, &mut fonts); if !image_data.is_empty() { - responses.push_front(FrontendMessage::UpdateImageData { image_data }.into()); + responses.push_front(FrontendMessage::UpdateImageData { document_id, image_data }.into()); } for font in fonts { responses.push_front(FrontendMessage::TriggerFontLoad { font, is_default: false }.into()); diff --git a/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs b/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs index 5afedb264..1de100161 100644 --- a/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs +++ b/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs @@ -50,11 +50,9 @@ impl Default for NavigationMessageHandler { impl MessageHandler for NavigationMessageHandler { #[remain::check] - fn process_message(&mut self, message: NavigationMessage, data: (&Document, &InputPreprocessorMessageHandler), responses: &mut VecDeque) { + fn process_message(&mut self, message: NavigationMessage, (document, ipp): (&Document, &InputPreprocessorMessageHandler), responses: &mut VecDeque) { use NavigationMessage::*; - let (document, ipp) = data; - #[remain::sorted] match message { DecreaseCanvasZoom { center_on_mouse } => { diff --git a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs index ecdee6f5c..007d5d262 100644 --- a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs +++ b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs @@ -31,7 +31,7 @@ impl MessageHandler>, document: TargetDocument }, + SetImaginateCfgScale { cfg_scale: f64 }, + SetImaginateDenoisingStrength { denoising_strength: f64 }, + SetImaginateNegativePrompt { negative_prompt: String }, + SetImaginatePrompt { prompt: String }, + SetImaginateRestoreFaces { restore_faces: bool }, + SetImaginateSamples { samples: u32 }, + SetImaginateSamplingMethod { method: ImaginateSamplingMethod }, + SetImaginateScaleFromResolution, + SetImaginateSeed { seed: u64 }, + SetImaginateSeedRandomize, + SetImaginateSeedRandomizeAndGenerate, + SetImaginateTiling { tiling: bool }, + SetImaginateUseImg2Img { use_img2img: bool }, SetPivot { new_position: PivotPosition }, UpdateSelectedDocumentProperties, } diff --git a/editor/src/messages/portfolio/document/properties_panel/properties_panel_message_handler.rs b/editor/src/messages/portfolio/document/properties_panel/properties_panel_message_handler.rs index 9e9459301..19fd93fa5 100644 --- a/editor/src/messages/portfolio/document/properties_panel/properties_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/properties_panel/properties_panel_message_handler.rs @@ -1,9 +1,11 @@ use super::utility_functions::{register_artboard_layer_properties, register_artwork_layer_properties}; use super::utility_types::PropertiesPanelMessageHandlerData; +use crate::application::generate_uuid; use crate::messages::layout::utility_types::layout_widget::{Layout, WidgetLayout}; use crate::messages::layout::utility_types::misc::LayoutTarget; use crate::messages::portfolio::document::properties_panel::utility_functions::apply_transform_operation; use crate::messages::portfolio::document::utility_types::misc::TargetDocument; +use crate::messages::portfolio::utility_types::PersistentData; use crate::messages::prelude::*; use graphene::{LayerId, Operation}; @@ -15,14 +17,13 @@ pub struct PropertiesPanelMessageHandler { active_selection: Option<(Vec, TargetDocument)>, } -impl<'a> MessageHandler> for PropertiesPanelMessageHandler { +impl<'a> MessageHandler)> for PropertiesPanelMessageHandler { #[remain::check] - fn process_message(&mut self, message: PropertiesPanelMessage, data: PropertiesPanelMessageHandlerData, responses: &mut VecDeque) { + fn process_message(&mut self, message: PropertiesPanelMessage, (persistent_data, data): (&PersistentData, PropertiesPanelMessageHandlerData), responses: &mut VecDeque) { let PropertiesPanelMessageHandlerData { artwork_document, artboard_document, selected_layers, - font_cache, } = data; let get_document = |document_selector: TargetDocument| match document_selector { TargetDocument::Artboard => artboard_document, @@ -81,7 +82,7 @@ impl<'a> MessageHandler MessageHandler register_artboard_layer_properties(layer, responses, font_cache), - TargetDocument::Artwork => register_artwork_layer_properties(layer, responses, font_cache), + TargetDocument::Artboard => register_artboard_layer_properties(layer, responses, persistent_data), + TargetDocument::Artwork => register_artwork_layer_properties(layer, responses, persistent_data), } } } @@ -148,6 +149,62 @@ impl<'a> MessageHandler { + let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer"); + responses.push_back(Operation::ImaginateSetPrompt { path, prompt }.into()); + } + SetImaginateNegativePrompt { negative_prompt } => { + let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer"); + responses.push_back(Operation::ImaginateSetNegativePrompt { path, negative_prompt }.into()); + } + SetImaginateDenoisingStrength { denoising_strength } => { + let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer"); + responses.push_back(Operation::ImaginateSetDenoisingStrength { path, denoising_strength }.into()); + } + SetImaginateSamples { samples } => { + let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer"); + responses.push_back(Operation::ImaginateSetSamples { path, samples }.into()); + } + SetImaginateSamplingMethod { method } => { + let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer"); + responses.push_back(Operation::SetImaginateSamplingMethod { path, method }.into()); + } + SetImaginateScaleFromResolution => { + let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer"); + + responses.push_back(Operation::ImaginateSetScaleFromResolution { path }.into()); + } + SetImaginateSeed { seed } => { + let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer"); + responses.push_back(Operation::ImaginateSetSeed { path, seed }.into()); + } + SetImaginateSeedRandomize => { + let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer"); + let seed = generate_uuid(); + responses.push_back(Operation::ImaginateSetSeed { path, seed }.into()); + } + SetImaginateSeedRandomizeAndGenerate => { + let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer"); + let seed = generate_uuid(); + responses.push_back(Operation::ImaginateSetSeed { path, seed }.into()); + responses.push_back(DocumentMessage::ImaginateGenerate.into()); + } + SetImaginateCfgScale { cfg_scale } => { + let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer"); + responses.push_back(Operation::ImaginateSetCfgScale { path, cfg_scale }.into()); + } + SetImaginateUseImg2Img { use_img2img } => { + let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer"); + responses.push_back(Operation::ImaginateSetUseImg2Img { path, use_img2img }.into()); + } + SetImaginateRestoreFaces { restore_faces } => { + let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer"); + responses.push_back(Operation::ImaginateSetRestoreFaces { path, restore_faces }.into()); + } + SetImaginateTiling { tiling } => { + let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer"); + responses.push_back(Operation::ImaginateSetTiling { path, tiling }.into()); + } } } diff --git a/editor/src/messages/portfolio/document/properties_panel/utility_functions.rs b/editor/src/messages/portfolio/document/properties_panel/utility_functions.rs index e63d52402..f7bc2d662 100644 --- a/editor/src/messages/portfolio/document/properties_panel/utility_functions.rs +++ b/editor/src/messages/portfolio/document/properties_panel/utility_functions.rs @@ -2,17 +2,22 @@ use super::utility_types::TransformOp; use crate::messages::layout::utility_types::layout_widget::{Layout, LayoutGroup, Widget, WidgetCallback, WidgetHolder, WidgetLayout}; use crate::messages::layout::utility_types::misc::LayoutTarget; use crate::messages::layout::utility_types::widgets::assist_widgets::PivotAssist; -use crate::messages::layout::utility_types::widgets::button_widgets::PopoverButton; -use crate::messages::layout::utility_types::widgets::input_widgets::{ColorInput, FontInput, NumberInput, RadioEntryData, RadioInput, TextAreaInput, TextInput}; -use crate::messages::layout::utility_types::widgets::label_widgets::{IconLabel, IconStyle, Separator, SeparatorDirection, SeparatorType, TextLabel}; +use crate::messages::layout::utility_types::widgets::button_widgets::{IconButton, PopoverButton, TextButton}; +use crate::messages::layout::utility_types::widgets::input_widgets::{ + CheckboxInput, ColorInput, DropdownEntryData, DropdownInput, FontInput, NumberInput, RadioEntryData, RadioInput, TextAreaInput, TextInput, +}; +use crate::messages::layout::utility_types::widgets::label_widgets::{IconLabel, Separator, SeparatorDirection, SeparatorType, TextLabel}; +use crate::messages::portfolio::utility_types::{ImaginateServerStatus, PersistentData}; use crate::messages::prelude::*; -use glam::{DAffine2, DVec2}; use graphene::color::Color; +use graphene::document::pick_layer_safe_imaginate_resolution; +use graphene::layers::imaginate_layer::{ImaginateLayer, ImaginateSamplingMethod, ImaginateStatus}; use graphene::layers::layer_info::{Layer, LayerDataType, LayerDataTypeDiscriminant}; use graphene::layers::style::{Fill, Gradient, GradientType, LineCap, LineJoin, Stroke}; use graphene::layers::text_layer::{FontCache, TextLayer}; +use glam::{DAffine2, DVec2}; use std::f64::consts::PI; use std::rc::Rc; @@ -34,12 +39,12 @@ pub fn apply_transform_operation(layer: &Layer, transform_op: TransformOp, value transformation(layer.transform, value / scale).to_cols_array() } -pub fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDeque, font_cache: &FontCache) { +pub fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDeque, persistent_data: &PersistentData) { let options_bar = vec![LayoutGroup::Row { widgets: vec![ WidgetHolder::new(Widget::IconLabel(IconLabel { icon: "NodeArtboard".into(), - icon_style: IconStyle::Node, + tooltip: "Artboard".into(), })), WidgetHolder::new(Widget::Separator(Separator { separator_type: SeparatorType::Related, @@ -81,7 +86,7 @@ pub fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDequ } else { panic!("Artboard must have a solid fill") }; - let pivot = layer.transform.transform_vector2(layer.layerspace_pivot(font_cache)); + let pivot = layer.transform.transform_vector2(layer.layerspace_pivot(&persistent_data.font_cache)); vec![LayoutGroup::Section { name: "Artboard".into(), @@ -139,7 +144,7 @@ pub fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDequ direction: SeparatorDirection::Horizontal, })), WidgetHolder::new(Widget::NumberInput(NumberInput { - value: Some(layer.bounding_transform(font_cache).scale_x()), + value: Some(layer.bounding_transform(&persistent_data.font_cache).scale_x()), label: "W".into(), unit: " px".into(), on_update: WidgetCallback::new(|number_input: &NumberInput| { @@ -156,7 +161,7 @@ pub fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDequ direction: SeparatorDirection::Horizontal, })), WidgetHolder::new(Widget::NumberInput(NumberInput { - value: Some(layer.bounding_transform(font_cache).scale_y()), + value: Some(layer.bounding_transform(&persistent_data.font_cache).scale_y()), label: "H".into(), unit: " px".into(), on_update: WidgetCallback::new(|number_input: &NumberInput| { @@ -219,25 +224,29 @@ pub fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDequ ); } -pub fn register_artwork_layer_properties(layer: &Layer, responses: &mut VecDeque, font_cache: &FontCache) { +pub fn register_artwork_layer_properties(layer: &Layer, responses: &mut VecDeque, persistent_data: &PersistentData) { let options_bar = vec![LayoutGroup::Row { widgets: vec![ match &layer.data { LayerDataType::Folder(_) => WidgetHolder::new(Widget::IconLabel(IconLabel { icon: "NodeFolder".into(), - icon_style: IconStyle::Node, + tooltip: "Folder".into(), })), LayerDataType::Shape(_) => WidgetHolder::new(Widget::IconLabel(IconLabel { icon: "NodeShape".into(), - icon_style: IconStyle::Node, + tooltip: "Shape".into(), })), LayerDataType::Text(_) => WidgetHolder::new(Widget::IconLabel(IconLabel { icon: "NodeText".into(), - icon_style: IconStyle::Node, + tooltip: "Text".into(), })), LayerDataType::Image(_) => WidgetHolder::new(Widget::IconLabel(IconLabel { icon: "NodeImage".into(), - icon_style: IconStyle::Node, + tooltip: "Image".into(), + })), + LayerDataType::Imaginate(_) => WidgetHolder::new(Widget::IconLabel(IconLabel { + icon: "NodeImaginate".into(), + tooltip: "Imaginate".into(), })), }, WidgetHolder::new(Widget::Separator(Separator { @@ -272,24 +281,31 @@ pub fn register_artwork_layer_properties(layer: &Layer, responses: &mut VecDeque let properties_body = match &layer.data { LayerDataType::Shape(shape) => { if let Some(fill_layout) = node_section_fill(shape.style.fill()) { - vec![node_section_transform(layer, font_cache), fill_layout, node_section_stroke(&shape.style.stroke().unwrap_or_default())] + vec![ + node_section_transform(layer, persistent_data), + fill_layout, + node_section_stroke(&shape.style.stroke().unwrap_or_default()), + ] } else { - vec![node_section_transform(layer, font_cache), node_section_stroke(&shape.style.stroke().unwrap_or_default())] + vec![node_section_transform(layer, persistent_data), node_section_stroke(&shape.style.stroke().unwrap_or_default())] } } LayerDataType::Text(text) => { vec![ - node_section_transform(layer, font_cache), + node_section_transform(layer, persistent_data), node_section_font(text), node_section_fill(text.path_style.fill()).expect("Text should have fill"), node_section_stroke(&text.path_style.stroke().unwrap_or_default()), ] } LayerDataType::Image(_) => { - vec![node_section_transform(layer, font_cache)] + vec![node_section_transform(layer, persistent_data)] } - _ => { - vec![] + LayerDataType::Imaginate(imaginate) => { + vec![node_section_transform(layer, persistent_data), node_section_imaginate(imaginate, layer, persistent_data, responses)] + } + LayerDataType::Folder(_) => { + vec![node_section_transform(layer, persistent_data)] } }; @@ -309,8 +325,8 @@ pub fn register_artwork_layer_properties(layer: &Layer, responses: &mut VecDeque ); } -fn node_section_transform(layer: &Layer, font_cache: &FontCache) -> LayoutGroup { - let pivot = layer.transform.transform_vector2(layer.layerspace_pivot(font_cache)); +fn node_section_transform(layer: &Layer, persistent_data: &PersistentData) -> LayoutGroup { + let pivot = layer.transform.transform_vector2(layer.layerspace_pivot(&persistent_data.font_cache)); LayoutGroup::Section { name: "Transform".into(), layout: vec![ @@ -442,7 +458,7 @@ fn node_section_transform(layer: &Layer, font_cache: &FontCache) -> LayoutGroup direction: SeparatorDirection::Horizontal, })), WidgetHolder::new(Widget::NumberInput(NumberInput { - value: Some(layer.bounding_transform(font_cache).scale_x()), + value: Some(layer.bounding_transform(&persistent_data.font_cache).scale_x()), label: "W".into(), unit: " px".into(), on_update: WidgetCallback::new(|number_input: &NumberInput| { @@ -459,7 +475,7 @@ fn node_section_transform(layer: &Layer, font_cache: &FontCache) -> LayoutGroup direction: SeparatorDirection::Horizontal, })), WidgetHolder::new(Widget::NumberInput(NumberInput { - value: Some(layer.bounding_transform(font_cache).scale_y()), + value: Some(layer.bounding_transform(&persistent_data.font_cache).scale_y()), label: "H".into(), unit: " px".into(), on_update: WidgetCallback::new(|number_input: &NumberInput| { @@ -477,6 +493,524 @@ fn node_section_transform(layer: &Layer, font_cache: &FontCache) -> LayoutGroup } } +fn node_section_imaginate(imaginate_layer: &ImaginateLayer, layer: &Layer, persistent_data: &PersistentData, responses: &mut VecDeque) -> LayoutGroup { + LayoutGroup::Section { + name: "Imaginate".into(), + layout: vec![ + LayoutGroup::Row { + widgets: { + let tooltip = "Connection status to the server that computes generated images".to_string(); + + vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Server".into(), + tooltip: tooltip.clone(), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::IconButton(IconButton { + size: 24, + icon: "Settings".into(), + tooltip: "Preferences: Imaginate".into(), + on_update: WidgetCallback::new(|_| DialogMessage::RequestPreferencesDialog.into()), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Related, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: { + match &persistent_data.imaginate_server_status { + ImaginateServerStatus::Unknown => { + responses.push_back(PortfolioMessage::ImaginateCheckServerStatus.into()); + "Checking...".into() + } + ImaginateServerStatus::Checking => "Checking...".into(), + ImaginateServerStatus::Unavailable => "Unavailable".into(), + ImaginateServerStatus::Connected => "Connected".into(), + } + }, + bold: true, + tooltip, + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Related, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::IconButton(IconButton { + size: 24, + icon: "Reload".into(), + tooltip: "Refresh connection status".into(), + on_update: WidgetCallback::new(|_| PortfolioMessage::ImaginateCheckServerStatus.into()), + ..Default::default() + })), + ] + }, + }, + LayoutGroup::Row { + widgets: { + let tooltip = "When generating, the percentage represents how many sampling steps have so far been processed out of the target number".to_string(); + + vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Progress".into(), + tooltip: tooltip.clone(), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: { + // Since we don't serialize the status, we need to derive from other state whether the Idle state is actually supposed to be the Terminated state + let mut interpreted_status = imaginate_layer.status.clone(); + if imaginate_layer.status == ImaginateStatus::Idle + && imaginate_layer.blob_url.is_some() + && imaginate_layer.percent_complete > 0. + && imaginate_layer.percent_complete < 100. + { + interpreted_status = ImaginateStatus::Terminated; + } + + match interpreted_status { + ImaginateStatus::Idle => match imaginate_layer.blob_url { + Some(_) => "Done".into(), + None => "Ready".into(), + }, + ImaginateStatus::Beginning => "Beginning...".into(), + ImaginateStatus::Uploading(percent) => format!("Uploading Base Image: {:.0}%", percent), + ImaginateStatus::Generating => format!("Generating: {:.0}%", imaginate_layer.percent_complete), + ImaginateStatus::Terminating => "Terminating...".into(), + ImaginateStatus::Terminated => format!("{:.0}% (Terminated)", imaginate_layer.percent_complete), + } + }, + bold: true, + tooltip, + ..Default::default() + })), + ] + }, + }, + LayoutGroup::Row { + widgets: [ + vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Image".into(), + tooltip: "Buttons that control the image generation process".into(), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + ], + { + match imaginate_layer.status { + ImaginateStatus::Beginning | ImaginateStatus::Uploading(_) => vec![WidgetHolder::new(Widget::TextButton(TextButton { + label: "Beginning...".into(), + tooltip: "Sending image generation request to the server".into(), + disabled: true, + ..Default::default() + }))], + ImaginateStatus::Generating => vec![WidgetHolder::new(Widget::TextButton(TextButton { + label: "Terminate".into(), + tooltip: "Cancel in-progress image generation and keep the latest progress".into(), + on_update: WidgetCallback::new(|_| DocumentMessage::ImaginateTerminate.into()), + ..Default::default() + }))], + ImaginateStatus::Terminating => vec![WidgetHolder::new(Widget::TextButton(TextButton { + label: "Terminating...".into(), + tooltip: "Waiting on the final image generated after termination".into(), + disabled: true, + ..Default::default() + }))], + ImaginateStatus::Idle | ImaginateStatus::Terminated => vec![ + WidgetHolder::new(Widget::IconButton(IconButton { + size: 24, + icon: "Random".into(), + tooltip: "Generate with a random seed".into(), + on_update: WidgetCallback::new(|_| PropertiesPanelMessage::SetImaginateSeedRandomizeAndGenerate.into()), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Related, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::TextButton(TextButton { + label: "Generate".into(), + tooltip: "Fill layer frame by generating a new image".into(), + on_update: WidgetCallback::new(|_| DocumentMessage::ImaginateGenerate.into()), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Related, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::TextButton(TextButton { + label: "Clear".into(), + tooltip: "Remove generated image from the layer frame".into(), + disabled: imaginate_layer.blob_url == None, + on_update: WidgetCallback::new(|_| DocumentMessage::ImaginateClear.into()), + ..Default::default() + })), + ], + } + }, + ] + .concat(), + }, + LayoutGroup::Row { + widgets: { + let tooltip = "Seed determines the random outcome, enabling limitless unique variations".to_string(); + + vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Seed".into(), + tooltip: tooltip.clone(), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::IconButton(IconButton { + size: 24, + icon: "Regenerate".into(), + tooltip: "Set a new random seed".into(), + on_update: WidgetCallback::new(|_| PropertiesPanelMessage::SetImaginateSeedRandomize.into()), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Related, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::NumberInput(NumberInput { + value: Some(imaginate_layer.seed as f64), + min: Some(-1.), + tooltip, + on_update: WidgetCallback::new(move |number_input: &NumberInput| { + PropertiesPanelMessage::SetImaginateSeed { + seed: number_input.value.unwrap().round() as u64, + } + .into() + }), + ..Default::default() + })), + ] + }, + }, + LayoutGroup::Row { + widgets: { + let tooltip = " + Width and height of the image that will be generated. Larger resolutions take longer to compute.\n\ + \n\ + 512x512 yields optimal results because the AI is trained to understand that scale best. Larger sizes may tend to integrate the prompt's subject more than once. Small sizes are often incoherent. Put the layer in a folder and resize that to keep resolution unchanged.\n\ + \n\ + Dimensions must be a multiple of 64, so these are set by rounding the layer dimensions. A resolution exceeding 1 megapixel is reduced below that limit because larger sizes may exceed available GPU memory on the server. + ".trim().to_string(); + + vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Resolution".into(), + tooltip: tooltip.clone(), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::IconButton(IconButton { + size: 24, + icon: "Rescale".into(), + tooltip: "Set the layer scale to this resolution".into(), + on_update: WidgetCallback::new(|_| PropertiesPanelMessage::SetImaginateScaleFromResolution.into()), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Related, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: { + let (width, height) = pick_layer_safe_imaginate_resolution(layer, &persistent_data.font_cache); + format!("{} W x {} H", width, height) + }, + tooltip, + bold: true, + ..Default::default() + })), + ] + }, + }, + LayoutGroup::Row { + widgets: { + let tooltip = "Number of iterations to improve the image generation quality, with diminishing returns around 40".to_string(); + vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Sampling Steps".into(), + tooltip: tooltip.clone(), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::NumberInput(NumberInput { + value: Some(imaginate_layer.samples.into()), + min: Some(0.), + max: Some(150.), + tooltip, + on_update: WidgetCallback::new(move |number_input: &NumberInput| { + PropertiesPanelMessage::SetImaginateSamples { + samples: number_input.value.unwrap().round() as u32, + } + .into() + }), + ..Default::default() + })), + ] + }, + }, + LayoutGroup::Row { + widgets: { + let tooltip = " + Algorithm used to generate the image during each sampling step.\n\ + \n\ + 'DPM Fast' and 'DPM Adaptive' do not support live refreshing updates. + " + .trim() + .to_string(); + + let sampling_methods = ImaginateSamplingMethod::list(); + let mut entries = Vec::with_capacity(sampling_methods.len()); + for method in sampling_methods { + entries.push(DropdownEntryData { + label: method.to_string(), + on_update: WidgetCallback::new(move |_| PropertiesPanelMessage::SetImaginateSamplingMethod { method }.into()), + ..DropdownEntryData::default() + }); + } + let entries = vec![entries]; + + vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Sampling Method".into(), + tooltip: tooltip.clone(), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::DropdownInput(DropdownInput { + entries, + selected_index: Some(imaginate_layer.sampling_method as u32), + tooltip, + ..Default::default() + })), + ] + }, + }, + LayoutGroup::Row { + widgets: { + let tooltip = "Generate an image based upon the artwork beneath this frame in the containing folder".to_string(); + + vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Use Base Image".into(), + tooltip: tooltip.clone(), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::CheckboxInput(CheckboxInput { + checked: imaginate_layer.use_img2img, + tooltip, + on_update: WidgetCallback::new(move |checkbox_input: &CheckboxInput| PropertiesPanelMessage::SetImaginateUseImg2Img { use_img2img: checkbox_input.checked }.into()), + ..Default::default() + })), + ] + }, + }, + LayoutGroup::Row { + widgets: { + let tooltip = "Strength of the artistic liberties allowing changes from the base image. The image is unaltered at 0 and completely different at 1.".to_string(); + vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Image Creativity".into(), + tooltip: tooltip.clone(), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::NumberInput(NumberInput { + value: Some(imaginate_layer.denoising_strength), + min: Some(0.), + max: Some(1.), + disabled: !imaginate_layer.use_img2img, + tooltip, + on_update: WidgetCallback::new(move |number_input: &NumberInput| { + PropertiesPanelMessage::SetImaginateDenoisingStrength { + denoising_strength: number_input.value.unwrap(), + } + .into() + }), + ..Default::default() + })), + ] + }, + }, + LayoutGroup::Row { + widgets: { + let tooltip = + "Amplification of the text prompt's influence over the outcome. Lower values are more creative and exploratory. Higher values are more literal and uninspired.".to_string(); + + vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Text Rigidness".into(), + tooltip: tooltip.to_string(), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::NumberInput(NumberInput { + value: Some(imaginate_layer.cfg_scale), + min: Some(0.), + max: Some(30.), + tooltip, + on_update: WidgetCallback::new(move |number_input: &NumberInput| { + PropertiesPanelMessage::SetImaginateCfgScale { + cfg_scale: number_input.value.unwrap(), + } + .into() + }), + ..Default::default() + })), + ] + }, + }, + LayoutGroup::Row { + widgets: vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Text Prompt".into(), + tooltip: " + Description of the desired image subject and style.\n\ + \n\ + Include an artist name like \"Rembrandt\" or art medium like \"watercolor\" or \"photography\" to influence the look. List multiple to meld styles.\n\ + \n\ + To boost the importance of a word or phrase, wrap it in quotes ending with a colon and a multiplier, for example:\n\ + \"(colorless:0.7) green (ideas sleep:1.3) furiously\" + " + .trim() + .into(), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::TextAreaInput(TextAreaInput { + value: imaginate_layer.prompt.clone(), + on_update: WidgetCallback::new(move |text_area_input: &TextAreaInput| { + PropertiesPanelMessage::SetImaginatePrompt { + prompt: text_area_input.value.clone(), + } + .into() + }), + ..Default::default() + })), + ], + }, + LayoutGroup::Row { + widgets: vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Neg. Prompt".into(), + tooltip: "A negative text prompt can be used to list things like objects or colors to avoid".into(), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::TextAreaInput(TextAreaInput { + value: imaginate_layer.negative_prompt.clone(), + on_update: WidgetCallback::new(move |text_area_input: &TextAreaInput| { + PropertiesPanelMessage::SetImaginateNegativePrompt { + negative_prompt: text_area_input.value.clone(), + } + .into() + }), + ..Default::default() + })), + ], + }, + LayoutGroup::Row { + widgets: { + let tooltip = "Postprocess human (or human-like) faces to look subtly less distorted".to_string(); + + vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Improve Faces".into(), + tooltip: tooltip.clone(), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::CheckboxInput(CheckboxInput { + checked: imaginate_layer.restore_faces, + tooltip, + on_update: WidgetCallback::new(move |checkbox_input: &CheckboxInput| { + PropertiesPanelMessage::SetImaginateRestoreFaces { + restore_faces: checkbox_input.checked, + } + .into() + }), + ..Default::default() + })), + ] + }, + }, + LayoutGroup::Row { + widgets: { + let tooltip = "Generate the image so its edges loop seamlessly to make repeatable patterns or textures".to_string(); + + vec![ + WidgetHolder::new(Widget::TextLabel(TextLabel { + value: "Tiling".into(), + tooltip: tooltip.clone(), + ..Default::default() + })), + WidgetHolder::new(Widget::Separator(Separator { + separator_type: SeparatorType::Unrelated, + direction: SeparatorDirection::Horizontal, + })), + WidgetHolder::new(Widget::CheckboxInput(CheckboxInput { + checked: imaginate_layer.tiling, + tooltip, + on_update: WidgetCallback::new(move |checkbox_input: &CheckboxInput| PropertiesPanelMessage::SetImaginateTiling { tiling: checkbox_input.checked }.into()), + ..Default::default() + })), + ] + }, + }, + ], + } +} + fn node_section_font(layer: &TextLayer) -> LayoutGroup { let font = layer.font.clone(); let size = layer.size; diff --git a/editor/src/messages/portfolio/document/properties_panel/utility_types.rs b/editor/src/messages/portfolio/document/properties_panel/utility_types.rs index fdd32b7b1..8b4d1ef34 100644 --- a/editor/src/messages/portfolio/document/properties_panel/utility_types.rs +++ b/editor/src/messages/portfolio/document/properties_panel/utility_types.rs @@ -1,5 +1,4 @@ use graphene::document::Document as GrapheneDocument; -use graphene::layers::text_layer::FontCache; use graphene::LayerId; use serde::{Deserialize, Serialize}; @@ -8,7 +7,6 @@ pub struct PropertiesPanelMessageHandlerData<'a> { pub artwork_document: &'a GrapheneDocument, pub artboard_document: &'a GrapheneDocument, pub selected_layers: &'a mut dyn Iterator, - pub font_cache: &'a FontCache, } #[derive(PartialEq, Eq, Clone, Copy, Debug, Serialize, Deserialize)] diff --git a/editor/src/messages/portfolio/document/utility_types/layer_panel.rs b/editor/src/messages/portfolio/document/utility_types/layer_panel.rs index 7195d90ff..263d62df9 100644 --- a/editor/src/messages/portfolio/document/utility_types/layer_panel.rs +++ b/editor/src/messages/portfolio/document/utility_types/layer_panel.rs @@ -72,7 +72,7 @@ impl LayerPanelEntry { let arr = arr.iter().map(|x| (*x).into()).collect::>(); let mut thumbnail = String::new(); let mut svg_defs = String::new(); - let render_data = RenderData::new(ViewMode::Normal, font_cache, None, false); + let render_data = RenderData::new(ViewMode::Normal, font_cache, None); layer.data.clone().render(&mut thumbnail, &mut svg_defs, &mut vec![transform], render_data); let transform = transform.to_cols_array().iter().map(ToString::to_string).collect::>().join(","); let thumbnail = if let [(x_min, y_min), (x_max, y_max)] = arr.as_slice() { diff --git a/editor/src/messages/portfolio/document/utility_types/misc.rs b/editor/src/messages/portfolio/document/utility_types/misc.rs index 18560403d..6e18edb46 100644 --- a/editor/src/messages/portfolio/document/utility_types/misc.rs +++ b/editor/src/messages/portfolio/document/utility_types/misc.rs @@ -44,12 +44,11 @@ pub enum DocumentMode { impl fmt::Display for DocumentMode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let text = match self { - DocumentMode::DesignMode => "Design Mode".to_string(), - DocumentMode::SelectMode => "Select Mode".to_string(), - DocumentMode::GuideMode => "Guide Mode".to_string(), - }; - write!(f, "{}", text) + match self { + DocumentMode::DesignMode => write!(f, "Design Mode"), + DocumentMode::SelectMode => write!(f, "Select Mode"), + DocumentMode::GuideMode => write!(f, "Guide Mode"), + } } } @@ -63,33 +62,7 @@ impl DocumentMode { } } -#[derive(PartialEq, Eq, Clone, Copy, Default, Debug, Serialize, Deserialize)] -pub enum Platform { - #[default] - Unknown, - Windows, - Mac, - Linux, -} - -impl Platform { - pub fn as_keyboard_platform_layout(&self) -> KeyboardPlatformLayout { - match self { - Platform::Mac => KeyboardPlatformLayout::Mac, - Platform::Unknown => { - warn!("The platform has not been set, remember to send `GlobalsMessage::SetPlatform` during editor initialization."); - KeyboardPlatformLayout::Standard - } - _ => KeyboardPlatformLayout::Standard, - } - } -} - -#[derive(PartialEq, Eq, Clone, Copy, Default, Debug, Serialize, Deserialize)] -pub enum KeyboardPlatformLayout { - /// Standard keyboard mapping used by Windows and Linux - #[default] - Standard, - /// Keyboard mapping used by Macs where Command is sometimes used in favor of Control - Mac, +pub enum DocumentRenderMode<'a> { + Root, + OnlyBelowLayerInFolder(&'a [LayerId]), } diff --git a/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs index 60bf907f8..e7db4be6d 100644 --- a/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs @@ -84,6 +84,13 @@ impl PropertyHolder for MenuBarMessageHandler { ..MenuBarEntry::default() }, ], + vec![MenuBarEntry { + label: "Preferences…".into(), + icon: Some("Settings".into()), + shortcut: action_keys!(DialogMessageDiscriminant::RequestPreferencesDialog), + action: MenuBarEntry::create_action(|_| DialogMessage::RequestPreferencesDialog.into()), + ..MenuBarEntry::default() + }], ]), ), MenuBarEntry::new_root( diff --git a/editor/src/messages/portfolio/mod.rs b/editor/src/messages/portfolio/mod.rs index 46f34cd78..f0832bcca 100644 --- a/editor/src/messages/portfolio/mod.rs +++ b/editor/src/messages/portfolio/mod.rs @@ -3,6 +3,7 @@ mod portfolio_message_handler; pub mod document; pub mod menu_bar; +pub mod utility_types; #[doc(inline)] pub use portfolio_message::{PortfolioMessage, PortfolioMessageDiscriminant}; diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 0d086a9f7..25ab867bb 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -1,7 +1,8 @@ +use super::utility_types::ImaginateServerStatus; use crate::messages::portfolio::document::utility_types::clipboards::Clipboard; use crate::messages::prelude::*; -use graphene::layers::text_layer::Font; +use graphene::layers::{imaginate_layer::ImaginateStatus, text_layer::Font}; use graphene::LayerId; use serde::{Deserialize, Serialize}; @@ -13,12 +14,17 @@ pub enum PortfolioMessage { // Sub-messages #[remain::unsorted] #[child] - Document(DocumentMessage), + MenuBar(MenuBarMessage), #[remain::unsorted] #[child] - MenuBar(MenuBarMessage), + Document(DocumentMessage), // Messages + #[remain::unsorted] + DocumentPassMessage { + document_id: u64, + message: DocumentMessage, + }, AutoSaveActiveDocument, AutoSaveDocument { document_id: u64, @@ -45,8 +51,31 @@ pub enum PortfolioMessage { data: Vec, is_default: bool, }, + ImaginateCheckServerStatus, + ImaginateSetBlobUrl { + document_id: u64, + layer_path: Vec, + blob_url: String, + resolution: (f64, f64), + }, + ImaginateSetGeneratingStatus { + document_id: u64, + path: Vec, + percent: Option, + status: ImaginateStatus, + }, + ImaginateSetImageData { + document_id: u64, + layer_path: Vec, + image_data: Vec, + }, + ImaginateSetServerStatus { + status: ImaginateServerStatus, + }, Import, - LoadDocumentResources, + LoadDocumentResources { + document_id: u64, + }, LoadFont { font: Font, is_default: bool, @@ -66,6 +95,7 @@ pub enum PortfolioMessage { document_is_saved: bool, document_serialized_content: String, }, + // TODO: Paste message is unused, delete it? Paste { clipboard: Clipboard, }, @@ -84,6 +114,12 @@ pub enum PortfolioMessage { SetActiveDocument { document_id: u64, }, + SetImageBlobUrl { + document_id: u64, + layer_path: Vec, + blob_url: String, + resolution: (f64, f64), + }, UpdateDocumentWidgets, UpdateOpenDocumentsList, } diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 783a7491f..cccaac313 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -1,3 +1,4 @@ +use super::utility_types::PersistentData; use crate::application::generate_uuid; use crate::consts::{DEFAULT_DOCUMENT_NAME, GRAPHITE_DOCUMENT_VERSION}; use crate::messages::dialog::simple_dialogs; @@ -5,10 +6,11 @@ use crate::messages::frontend::utility_types::FrontendDocumentDetails; use crate::messages::layout::utility_types::layout_widget::PropertyHolder; use crate::messages::layout::utility_types::misc::LayoutTarget; use crate::messages::portfolio::document::utility_types::clipboards::{Clipboard, CopyBufferEntry, INTERNAL_CLIPBOARD_COUNT}; +use crate::messages::portfolio::utility_types::ImaginateServerStatus; use crate::messages::prelude::*; use graphene::layers::layer_info::LayerDataTypeDiscriminant; -use graphene::layers::text_layer::{Font, FontCache}; +use graphene::layers::text_layer::Font; use graphene::Operation as DocumentOperation; #[derive(Debug, Clone, Default)] @@ -18,34 +20,39 @@ pub struct PortfolioMessageHandler { document_ids: Vec, active_document_id: Option, copy_buffer: [Vec; INTERNAL_CLIPBOARD_COUNT as usize], - font_cache: FontCache, + pub persistent_data: PersistentData, } -impl MessageHandler for PortfolioMessageHandler { +impl MessageHandler for PortfolioMessageHandler { #[remain::check] - fn process_message(&mut self, message: PortfolioMessage, ipp: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { - use DocumentMessage::*; - use PortfolioMessage::*; - + fn process_message(&mut self, message: PortfolioMessage, (ipp, preferences): (&InputPreprocessorMessageHandler, &PreferencesMessageHandler), responses: &mut VecDeque) { #[remain::sorted] match message { // Sub-messages #[remain::unsorted] - Document(message) => { - if let Some(document) = self.active_document_id.and_then(|id| self.documents.get_mut(&id)) { - document.process_message(message, (ipp, &self.font_cache), responses) + PortfolioMessage::MenuBar(message) => self.menu_bar_message_handler.process_message(message, (), responses), + #[remain::unsorted] + PortfolioMessage::Document(message) => { + if let Some(document_id) = self.active_document_id { + if let Some(document) = self.documents.get_mut(&document_id) { + document.process_message(message, (document_id, ipp, &self.persistent_data, preferences), responses) + } } } - #[remain::unsorted] - MenuBar(message) => self.menu_bar_message_handler.process_message(message, (), responses), // Messages - AutoSaveActiveDocument => { + #[remain::unsorted] + PortfolioMessage::DocumentPassMessage { document_id, message } => { + if let Some(document) = self.documents.get_mut(&document_id) { + document.process_message(message, (document_id, ipp, &self.persistent_data, preferences), responses) + } + } + PortfolioMessage::AutoSaveActiveDocument => { if let Some(document_id) = self.active_document_id { responses.push_back(PortfolioMessage::AutoSaveDocument { document_id }.into()); } } - AutoSaveDocument { document_id } => { + PortfolioMessage::AutoSaveDocument { document_id } => { let document = self.documents.get(&document_id).unwrap(); responses.push_back( FrontendMessage::TriggerIndexedDbWriteDocument { @@ -60,12 +67,12 @@ impl MessageHandler for Port .into(), ) } - CloseActiveDocumentWithConfirmation => { + PortfolioMessage::CloseActiveDocumentWithConfirmation => { if let Some(document_id) = self.active_document_id { responses.push_back(PortfolioMessage::CloseDocumentWithConfirmation { document_id }.into()); } } - CloseAllDocuments => { + PortfolioMessage::CloseAllDocuments => { if self.active_document_id.is_some() { responses.push_back(PropertiesPanelMessage::Deactivate.into()); responses.push_back(BroadcastEvent::ToolAbort.into()); @@ -79,7 +86,7 @@ impl MessageHandler for Port responses.push_back(PortfolioMessage::DestroyAllDocuments.into()); responses.push_back(PortfolioMessage::UpdateOpenDocumentsList.into()); } - CloseDocument { document_id } => { + PortfolioMessage::CloseDocument { document_id } => { let document_index = self.document_index(document_id); self.documents.remove(&document_id); self.document_ids.remove(document_index); @@ -107,9 +114,9 @@ impl MessageHandler for Port } // Send the new list of document tab names - responses.push_back(UpdateOpenDocumentsList.into()); + responses.push_back(PortfolioMessage::UpdateOpenDocumentsList.into()); responses.push_back(FrontendMessage::TriggerIndexedDbRemoveDocument { document_id }.into()); - responses.push_back(RenderDocument.into()); + responses.push_back(DocumentMessage::RenderDocument.into()); responses.push_back(DocumentMessage::DocumentStructureChanged.into()); if let Some(document) = self.active_document() { for layer in document.layer_metadata.keys() { @@ -117,7 +124,7 @@ impl MessageHandler for Port } } } - CloseDocumentWithConfirmation { document_id } => { + PortfolioMessage::CloseDocumentWithConfirmation { document_id } => { let target_document = self.documents.get(&document_id).unwrap(); if target_document.is_saved() { responses.push_back(BroadcastEvent::ToolAbort.into()); @@ -134,7 +141,7 @@ impl MessageHandler for Port responses.push_back(PortfolioMessage::SelectDocument { document_id }.into()); } } - Copy { clipboard } => { + PortfolioMessage::Copy { clipboard } => { // We can't use `self.active_document()` because it counts as an immutable borrow of the entirety of `self` if let Some(active_document) = self.active_document_id.and_then(|id| self.documents.get(&id)) { let copy_val = |buffer: &mut Vec| { @@ -162,24 +169,24 @@ impl MessageHandler for Port } } } - Cut { clipboard } => { - responses.push_back(Copy { clipboard }.into()); - responses.push_back(DeleteSelectedLayers.into()); + PortfolioMessage::Cut { clipboard } => { + responses.push_back(PortfolioMessage::Copy { clipboard }.into()); + responses.push_back(DocumentMessage::DeleteSelectedLayers.into()); } - DestroyAllDocuments => { + PortfolioMessage::DestroyAllDocuments => { // Empty the list of internal document data self.documents.clear(); self.document_ids.clear(); self.active_document_id = None; } - FontLoaded { + PortfolioMessage::FontLoaded { font_family, font_style, preview_url, data, is_default, } => { - self.font_cache.insert(Font::new(font_family, font_style), preview_url, data, is_default); + self.persistent_data.font_cache.insert(Font::new(font_family, font_style), preview_url, data, is_default); if let Some(document) = self.active_document_mut() { document.graphene_document.mark_all_layers_of_type_as_dirty(LayerDataTypeDiscriminant::Text); @@ -187,23 +194,64 @@ impl MessageHandler for Port responses.push_back(BroadcastEvent::DocumentIsDirty.into()); } } - Import => { + PortfolioMessage::ImaginateCheckServerStatus => { + self.persistent_data.imaginate_server_status = ImaginateServerStatus::Checking; + responses.push_back( + FrontendMessage::TriggerImaginateCheckServerStatus { + hostname: preferences.imaginate_server_hostname.clone(), + } + .into(), + ); + responses.push_back(PropertiesPanelMessage::ResendActiveProperties.into()); + } + PortfolioMessage::ImaginateSetBlobUrl { + document_id, + layer_path, + blob_url, + resolution, + } => { + if let Some(document) = self.documents.get_mut(&document_id) { + if let Ok(layer) = document.graphene_document.layer(&layer_path) { + let previous_blob_url = &layer.as_imaginate().unwrap().blob_url; + + if let Some(url) = previous_blob_url { + responses.push_back(FrontendMessage::TriggerRevokeBlobUrl { url: url.clone() }.into()); + } + + let message = DocumentOperation::SetLayerBlobUrl { layer_path, blob_url, resolution }.into(); + responses.push_back(PortfolioMessage::DocumentPassMessage { document_id, message }.into()); + } + } + } + PortfolioMessage::ImaginateSetGeneratingStatus { document_id, path, percent, status } => { + let message = DocumentOperation::ImaginateSetGeneratingStatus { path, percent, status }.into(); + responses.push_back(PortfolioMessage::DocumentPassMessage { document_id, message }.into()); + } + PortfolioMessage::ImaginateSetImageData { document_id, layer_path, image_data } => { + let message = DocumentOperation::ImaginateSetImageData { layer_path, image_data }.into(); + responses.push_back(PortfolioMessage::DocumentPassMessage { document_id, message }.into()); + } + PortfolioMessage::ImaginateSetServerStatus { status } => { + self.persistent_data.imaginate_server_status = status; + responses.push_back(PropertiesPanelMessage::ResendActiveProperties.into()); + } + PortfolioMessage::Import => { // This portfolio message wraps the frontend message so it can be listed as an action, which isn't possible for frontend messages if self.active_document().is_some() { responses.push_back(FrontendMessage::TriggerImport.into()); } } - LoadDocumentResources => { - if let Some(document) = self.active_document_mut() { - document.load_layer_resources(responses, &document.graphene_document.root.data, Vec::new()); + PortfolioMessage::LoadDocumentResources { document_id } => { + if let Some(document) = self.document_mut(document_id) { + document.load_layer_resources(responses, &document.graphene_document.root.data, Vec::new(), document_id); } } - LoadFont { font, is_default } => { - if !self.font_cache.loaded_font(&font) { + PortfolioMessage::LoadFont { font, is_default } => { + if !self.persistent_data.font_cache.loaded_font(&font) { responses.push_front(FrontendMessage::TriggerFontLoad { font, is_default }.into()); } } - NewDocumentWithName { name } => { + PortfolioMessage::NewDocumentWithName { name } => { let new_document = DocumentMessageHandler::with_name(name, ipp); let document_id = generate_uuid(); if self.active_document().is_some() { @@ -213,7 +261,7 @@ impl MessageHandler for Port self.load_document(new_document, document_id, responses); } - NextDocument => { + PortfolioMessage::NextDocument => { if let Some(active_document_id) = self.active_document_id { let current_index = self.document_index(active_document_id); let next_index = (current_index + 1) % self.document_ids.len(); @@ -222,11 +270,11 @@ impl MessageHandler for Port responses.push_back(PortfolioMessage::SelectDocument { document_id: next_id }.into()); } } - OpenDocument => { + PortfolioMessage::OpenDocument => { // This portfolio message wraps the frontend message so it can be listed as an action, which isn't possible for frontend messages responses.push_back(FrontendMessage::TriggerOpenDocument.into()); } - OpenDocumentFile { + PortfolioMessage::OpenDocumentFile { document_name, document_serialized_content, } => { @@ -240,7 +288,7 @@ impl MessageHandler for Port .into(), ); } - OpenDocumentFileWithId { + PortfolioMessage::OpenDocumentFileWithId { document_id, document_name, document_is_saved, @@ -261,7 +309,8 @@ impl MessageHandler for Port ), } } - Paste { clipboard } => { + // TODO: Paste message is unused, delete it? + PortfolioMessage::Paste { clipboard } => { let shallowest_common_folder = self.active_document().map(|document| { document .graphene_document @@ -270,20 +319,20 @@ impl MessageHandler for Port }); if let Some(folder) = shallowest_common_folder { - responses.push_back(DeselectAllLayers.into()); - responses.push_back(StartTransaction.into()); + responses.push_back(DocumentMessage::DeselectAllLayers.into()); + responses.push_back(DocumentMessage::StartTransaction.into()); responses.push_back( - PasteIntoFolder { + PortfolioMessage::PasteIntoFolder { clipboard, folder_path: folder.to_vec(), insert_index: -1, } .into(), ); - responses.push_back(CommitTransaction.into()); + responses.push_back(DocumentMessage::CommitTransaction.into()); } } - PasteIntoFolder { + PortfolioMessage::PasteIntoFolder { clipboard, folder_path: path, insert_index, @@ -300,10 +349,10 @@ impl MessageHandler for Port } .into(), ); - document.load_layer_resources(responses, &entry.layer.data, destination_path.clone()); + document.load_layer_resources(responses, &entry.layer.data, destination_path.clone(), self.active_document_id.unwrap()); responses.push_front( DocumentOperation::InsertLayer { - layer: entry.layer.clone(), + layer: Box::new(entry.layer.clone()), destination_path, insert_index, } @@ -322,15 +371,15 @@ impl MessageHandler for Port } } } - PasteSerializedData { data } => { + PortfolioMessage::PasteSerializedData { data } => { if let Some(document) = self.active_document() { if let Ok(data) = serde_json::from_str::>(&data) { let shallowest_common_folder = document .graphene_document .shallowest_common_folder(document.selected_layers()) .expect("While pasting from serialized, the selected layers did not exist while attempting to find the appropriate folder path for insertion"); - responses.push_back(DeselectAllLayers.into()); - responses.push_back(StartTransaction.into()); + responses.push_back(DocumentMessage::DeselectAllLayers.into()); + responses.push_back(DocumentMessage::StartTransaction.into()); for entry in data.iter().rev() { let destination_path = [shallowest_common_folder.to_vec(), vec![generate_uuid()]].concat(); @@ -342,10 +391,10 @@ impl MessageHandler for Port } .into(), ); - document.load_layer_resources(responses, &entry.layer.data, destination_path.clone()); + document.load_layer_resources(responses, &entry.layer.data, destination_path.clone(), self.active_document_id.unwrap()); responses.push_front( DocumentOperation::InsertLayer { - layer: entry.layer.clone(), + layer: Box::new(entry.layer.clone()), destination_path, insert_index: -1, } @@ -353,11 +402,11 @@ impl MessageHandler for Port ); } - responses.push_back(CommitTransaction.into()); + responses.push_back(DocumentMessage::CommitTransaction.into()); } } } - PrevDocument => { + PortfolioMessage::PrevDocument => { if let Some(active_document_id) = self.active_document_id { let len = self.document_ids.len(); let current_index = self.document_index(active_document_id); @@ -366,7 +415,7 @@ impl MessageHandler for Port responses.push_back(PortfolioMessage::SelectDocument { document_id: prev_id }.into()); } } - SelectDocument { document_id } => { + PortfolioMessage::SelectDocument { document_id } => { if let Some(document) = self.active_document() { if !document.is_saved() { // Safe to unwrap since we know that there is an active document @@ -384,10 +433,10 @@ impl MessageHandler for Port } // TODO: Remove this message in favor of having tools have specific data per document instance - responses.push_back(SetActiveDocument { document_id }.into()); + responses.push_back(PortfolioMessage::SetActiveDocument { document_id }.into()); responses.push_back(PortfolioMessage::UpdateOpenDocumentsList.into()); responses.push_back(FrontendMessage::UpdateActiveDocument { document_id }.into()); - responses.push_back(RenderDocument.into()); + responses.push_back(DocumentMessage::RenderDocument.into()); responses.push_back(DocumentMessage::DocumentStructureChanged.into()); for layer in self.documents.get(&document_id).unwrap().layer_metadata.keys() { responses.push_back(DocumentMessage::LayerChanged { affected_layer_path: layer.clone() }.into()); @@ -397,13 +446,27 @@ impl MessageHandler for Port responses.push_back(PortfolioMessage::UpdateDocumentWidgets.into()); responses.push_back(NavigationMessage::TranslateCanvas { delta: (0., 0.).into() }.into()); } - SetActiveDocument { document_id } => self.active_document_id = Some(document_id), - UpdateDocumentWidgets => { + PortfolioMessage::SetActiveDocument { document_id } => self.active_document_id = Some(document_id), + PortfolioMessage::SetImageBlobUrl { + document_id, + layer_path, + blob_url, + resolution, + } => { + let message = DocumentMessage::SetImageBlobUrl { + layer_path, + blob_url, + resolution, + document_id, + }; + responses.push_back(PortfolioMessage::DocumentPassMessage { document_id, message }.into()); + } + PortfolioMessage::UpdateDocumentWidgets => { if let Some(document) = self.active_document() { document.update_document_widgets(responses); } } - UpdateOpenDocumentsList => { + PortfolioMessage::UpdateOpenDocumentsList => { // Send the list of document tab names let open_documents = self .document_ids @@ -449,6 +512,14 @@ impl MessageHandler for Port } impl PortfolioMessageHandler { + pub fn document(&self, document_id: u64) -> Option<&DocumentMessageHandler> { + self.documents.get(&document_id) + } + + pub fn document_mut(&mut self, document_id: u64) -> Option<&mut DocumentMessageHandler> { + self.documents.get_mut(&document_id) + } + pub fn active_document(&self) -> Option<&DocumentMessageHandler> { self.active_document_id.and_then(|id| self.documents.get(&id)) } @@ -457,6 +528,10 @@ impl PortfolioMessageHandler { self.active_document_id.and_then(|id| self.documents.get_mut(&id)) } + pub fn active_document_id(&self) -> Option { + self.active_document_id + } + pub fn generate_new_document_name(&self) -> String { let mut doc_title_numbers = self .ordered_document_iterator() @@ -486,11 +561,11 @@ impl PortfolioMessageHandler { new_document .layer_metadata .keys() - .filter_map(|path| new_document.layer_panel_entry_from_path(path, &self.font_cache)) + .filter_map(|path| new_document.layer_panel_entry_from_path(path, &self.persistent_data.font_cache)) .map(|entry| FrontendMessage::UpdateDocumentLayerDetails { data: entry }.into()) .collect::>(), ); - new_document.update_layer_tree_options_bar_widgets(responses, &self.font_cache); + new_document.update_layer_tree_options_bar_widgets(responses, &self.persistent_data.font_cache); self.documents.insert(document_id, new_document); @@ -502,12 +577,12 @@ impl PortfolioMessageHandler { responses.push_back(PortfolioMessage::UpdateOpenDocumentsList.into()); responses.push_back(PortfolioMessage::SelectDocument { document_id }.into()); - responses.push_back(PortfolioMessage::LoadDocumentResources.into()); + responses.push_back(PortfolioMessage::LoadDocumentResources { document_id }.into()); responses.push_back(PortfolioMessage::UpdateDocumentWidgets.into()); responses.push_back(ToolMessage::InitTools.into()); responses.push_back(PropertiesPanelMessage::Init.into()); responses.push_back(NavigationMessage::TranslateCanvas { delta: (0., 0.).into() }.into()); - responses.push_back(DocumentMessage::DocumentStructureChanged.into()) + responses.push_back(DocumentMessage::DocumentStructureChanged.into()); } /// Returns an iterator over the open documents in order. @@ -518,8 +593,4 @@ impl PortfolioMessageHandler { fn document_index(&self, document_id: u64) -> usize { self.document_ids.iter().position(|id| id == &document_id).expect("Active document is missing from document ids") } - - pub fn font_cache(&self) -> &FontCache { - &self.font_cache - } } diff --git a/editor/src/messages/portfolio/utility_types.rs b/editor/src/messages/portfolio/utility_types.rs new file mode 100644 index 000000000..ad762d7ce --- /dev/null +++ b/editor/src/messages/portfolio/utility_types.rs @@ -0,0 +1,58 @@ +use graphene::layers::text_layer::FontCache; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug)] +pub struct PersistentData { + pub font_cache: FontCache, + pub imaginate_server_status: ImaginateServerStatus, +} + +impl Default for PersistentData { + fn default() -> Self { + Self { + font_cache: Default::default(), + imaginate_server_status: ImaginateServerStatus::Unknown, + } + } +} + +#[derive(PartialEq, Eq, Clone, Copy, Default, Debug, Serialize, Deserialize)] +pub enum ImaginateServerStatus { + #[default] + Unknown, + Checking, + Unavailable, + Connected, +} + +#[derive(PartialEq, Eq, Clone, Copy, Default, Debug, Serialize, Deserialize)] +pub enum Platform { + #[default] + Unknown, + Windows, + Mac, + Linux, +} + +impl Platform { + pub fn as_keyboard_platform_layout(&self) -> KeyboardPlatformLayout { + match self { + Platform::Mac => KeyboardPlatformLayout::Mac, + Platform::Unknown => { + warn!("The platform has not been set, remember to send `GlobalsMessage::SetPlatform` during editor initialization."); + KeyboardPlatformLayout::Standard + } + _ => KeyboardPlatformLayout::Standard, + } + } +} + +#[derive(PartialEq, Eq, Clone, Copy, Default, Debug, Serialize, Deserialize)] +pub enum KeyboardPlatformLayout { + /// Standard keyboard mapping used by Windows and Linux + #[default] + Standard, + /// Keyboard mapping used by Macs where Command is sometimes used in favor of Control + Mac, +} diff --git a/editor/src/messages/preferences/mod.rs b/editor/src/messages/preferences/mod.rs new file mode 100644 index 000000000..f61ee402c --- /dev/null +++ b/editor/src/messages/preferences/mod.rs @@ -0,0 +1,7 @@ +mod preferences_message; +mod preferences_message_handler; + +#[doc(inline)] +pub use preferences_message::{PreferencesMessage, PreferencesMessageDiscriminant}; +#[doc(inline)] +pub use preferences_message_handler::PreferencesMessageHandler; diff --git a/editor/src/messages/preferences/preferences_message.rs b/editor/src/messages/preferences/preferences_message.rs new file mode 100644 index 000000000..bf5b9de13 --- /dev/null +++ b/editor/src/messages/preferences/preferences_message.rs @@ -0,0 +1,13 @@ +use crate::messages::prelude::*; + +use serde::{Deserialize, Serialize}; + +#[impl_message(Message, Preferences)] +#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)] +pub enum PreferencesMessage { + Load { preferences: String }, + ResetToDefaults, + + ImaginateRefreshFrequency { seconds: f64 }, + ImaginateServerHostname { hostname: String }, +} diff --git a/editor/src/messages/preferences/preferences_message_handler.rs b/editor/src/messages/preferences/preferences_message_handler.rs new file mode 100644 index 000000000..9f066ce2e --- /dev/null +++ b/editor/src/messages/preferences/preferences_message_handler.rs @@ -0,0 +1,72 @@ +use crate::messages::prelude::*; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +pub struct PreferencesMessageHandler { + pub imaginate_server_hostname: String, + pub imaginate_refresh_frequency: f64, +} + +impl Default for PreferencesMessageHandler { + fn default() -> Self { + Self { + imaginate_server_hostname: "http://localhost:7860/".into(), + imaginate_refresh_frequency: 1., + } + } +} + +impl MessageHandler for PreferencesMessageHandler { + #[remain::check] + fn process_message(&mut self, message: PreferencesMessage, _data: (), responses: &mut VecDeque) { + match message { + PreferencesMessage::Load { preferences } => { + if let Ok(deserialized_preferences) = serde_json::from_str::(&preferences) { + *self = deserialized_preferences; + + if self.imaginate_server_hostname != Self::default().imaginate_server_hostname { + responses.push_back(PortfolioMessage::ImaginateCheckServerStatus.into()); + } + } + } + PreferencesMessage::ResetToDefaults => { + refresh_dialog(responses); + + *self = Self::default() + } + + PreferencesMessage::ImaginateRefreshFrequency { seconds } => { + self.imaginate_refresh_frequency = seconds; + responses.push_back(PortfolioMessage::ImaginateCheckServerStatus.into()); + } + PreferencesMessage::ImaginateServerHostname { hostname } => { + let initial = hostname.clone(); + let has_protocol = hostname.starts_with("http://") || hostname.starts_with("https://"); + let hostname = if has_protocol { hostname } else { "http://".to_string() + &hostname }; + let hostname = if hostname.ends_with('/') { hostname } else { hostname + "/" }; + + if hostname != initial { + refresh_dialog(responses); + } + + self.imaginate_server_hostname = hostname; + responses.push_back(PortfolioMessage::ImaginateCheckServerStatus.into()); + } + } + + responses.push_back(FrontendMessage::TriggerSavePreferences { preferences: self.clone() }.into()); + } + + advertise_actions!(PreferencesMessageDiscriminant; + ); +} + +fn refresh_dialog(responses: &mut VecDeque) { + responses.push_back( + DialogMessage::CloseDialogAndThen { + followups: vec![DialogMessage::RequestPreferencesDialog.into()], + } + .into(), + ); +} diff --git a/editor/src/messages/prelude.rs b/editor/src/messages/prelude.rs index 95bd4eff3..4eb1641fd 100644 --- a/editor/src/messages/prelude.rs +++ b/editor/src/messages/prelude.rs @@ -6,6 +6,7 @@ pub use crate::messages::broadcast::{BroadcastMessage, BroadcastMessageDiscrimin pub use crate::messages::debug::{DebugMessage, DebugMessageDiscriminant, DebugMessageHandler}; pub use crate::messages::dialog::export_dialog::{ExportDialogMessage, ExportDialogMessageDiscriminant, ExportDialogMessageHandler}; pub use crate::messages::dialog::new_document_dialog::{NewDocumentDialogMessage, NewDocumentDialogMessageDiscriminant, NewDocumentDialogMessageHandler}; +pub use crate::messages::dialog::preferences_dialog::{PreferencesDialogMessage, PreferencesDialogMessageDiscriminant, PreferencesDialogMessageHandler}; pub use crate::messages::dialog::{DialogMessage, DialogMessageDiscriminant, DialogMessageHandler}; pub use crate::messages::frontend::{FrontendMessage, FrontendMessageDiscriminant}; pub use crate::messages::globals::{GlobalsMessage, GlobalsMessageDiscriminant, GlobalsMessageHandler}; @@ -20,6 +21,7 @@ pub use crate::messages::portfolio::document::transform_layer::{TransformLayerMe pub use crate::messages::portfolio::document::{DocumentMessage, DocumentMessageDiscriminant, DocumentMessageHandler}; pub use crate::messages::portfolio::menu_bar::{MenuBarMessage, MenuBarMessageDiscriminant, MenuBarMessageHandler}; pub use crate::messages::portfolio::{PortfolioMessage, PortfolioMessageDiscriminant, PortfolioMessageHandler}; +pub use crate::messages::preferences::{PreferencesMessage, PreferencesMessageDiscriminant, PreferencesMessageHandler}; pub use crate::messages::tool::{ToolMessage, ToolMessageDiscriminant, ToolMessageHandler}; pub use crate::messages::workspace::{WorkspaceMessage, WorkspaceMessageDiscriminant, WorkspaceMessageHandler}; @@ -32,6 +34,7 @@ pub use crate::messages::tool::tool_messages::eyedropper_tool::{EyedropperToolMe pub use crate::messages::tool::tool_messages::fill_tool::{FillToolMessage, FillToolMessageDiscriminant}; pub use crate::messages::tool::tool_messages::freehand_tool::{FreehandToolMessage, FreehandToolMessageDiscriminant}; pub use crate::messages::tool::tool_messages::gradient_tool::{GradientToolMessage, GradientToolMessageDiscriminant}; +pub use crate::messages::tool::tool_messages::imaginate_tool::{ImaginateToolMessage, ImaginateToolMessageDiscriminant}; pub use crate::messages::tool::tool_messages::line_tool::{LineToolMessage, LineToolMessageDiscriminant}; pub use crate::messages::tool::tool_messages::navigate_tool::{NavigateToolMessage, NavigateToolMessageDiscriminant}; pub use crate::messages::tool::tool_messages::path_tool::{PathToolMessage, PathToolMessageDiscriminant}; diff --git a/editor/src/messages/tool/tool_message.rs b/editor/src/messages/tool/tool_message.rs index 9785a11eb..60c2695b7 100644 --- a/editor/src/messages/tool/tool_message.rs +++ b/editor/src/messages/tool/tool_message.rs @@ -75,6 +75,9 @@ pub enum ToolMessage { // #[remain::unsorted] // #[child] // Detail(DetailToolMessage), + #[remain::unsorted] + #[child] + Imaginate(ImaginateToolMessage), // Messages #[remain::unsorted] @@ -109,6 +112,9 @@ pub enum ToolMessage { #[remain::unsorted] ActivateToolShape, + #[remain::unsorted] + ActivateToolImaginate, + ActivateTool { tool_type: ToolType, }, diff --git a/editor/src/messages/tool/tool_message_handler.rs b/editor/src/messages/tool/tool_message_handler.rs index bc1e0358f..4de6f8369 100644 --- a/editor/src/messages/tool/tool_message_handler.rs +++ b/editor/src/messages/tool/tool_message_handler.rs @@ -2,21 +2,25 @@ use super::utility_types::{tool_message_to_tool_type, ToolFsmState}; use crate::application::generate_uuid; use crate::messages::layout::utility_types::layout_widget::PropertyHolder; use crate::messages::layout::utility_types::misc::LayoutTarget; +use crate::messages::portfolio::utility_types::PersistentData; use crate::messages::prelude::*; use crate::messages::tool::utility_types::ToolType; use graphene::color::Color; -use graphene::layers::text_layer::FontCache; #[derive(Debug, Default)] pub struct ToolMessageHandler { tool_state: ToolFsmState, } -impl MessageHandler for ToolMessageHandler { +impl MessageHandler for ToolMessageHandler { #[remain::check] - fn process_message(&mut self, message: ToolMessage, data: (&DocumentMessageHandler, &InputPreprocessorMessageHandler, &FontCache), responses: &mut VecDeque) { - let (document, input, font_cache) = data; + fn process_message( + &mut self, + message: ToolMessage, + (document, document_id, input, persistent_data): (&DocumentMessageHandler, u64, &InputPreprocessorMessageHandler, &PersistentData), + responses: &mut VecDeque, + ) { #[remain::sorted] match message { // Messages @@ -52,6 +56,9 @@ impl MessageHandler responses.push_front(ToolMessage::ActivateTool { tool_type: ToolType::Shape }.into()), + #[remain::unsorted] + ToolMessage::ActivateToolImaginate => responses.push_front(ToolMessage::ActivateTool { tool_type: ToolType::Imaginate }.into()), + ToolMessage::ActivateTool { tool_type } => { let tool_data = &mut self.tool_state.tool_data; let document_data = &self.tool_state.document_tool_data; @@ -66,12 +73,12 @@ impl MessageHandler { let document_data = &mut self.tool_state.document_tool_data; @@ -184,7 +191,7 @@ impl MessageHandler, ) -> Self { diff --git a/editor/src/messages/tool/tool_messages/ellipse_tool.rs b/editor/src/messages/tool/tool_messages/ellipse_tool.rs index 56e03f05d..9f885a7a1 100644 --- a/editor/src/messages/tool/tool_messages/ellipse_tool.rs +++ b/editor/src/messages/tool/tool_messages/ellipse_tool.rs @@ -122,7 +122,7 @@ impl Fsm for EllipseToolFsmState { self, event: ToolMessage, tool_data: &mut Self::ToolData, - (document, global_tool_data, input, font_cache): ToolActionHandlerData, + (document, _document_id, global_tool_data, input, font_cache): ToolActionHandlerData, _tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { diff --git a/editor/src/messages/tool/tool_messages/eyedropper_tool.rs b/editor/src/messages/tool/tool_messages/eyedropper_tool.rs index cc223e509..3d6e02ea3 100644 --- a/editor/src/messages/tool/tool_messages/eyedropper_tool.rs +++ b/editor/src/messages/tool/tool_messages/eyedropper_tool.rs @@ -104,7 +104,7 @@ impl Fsm for EyedropperToolFsmState { self, event: ToolMessage, _tool_data: &mut Self::ToolData, - (document, _global_tool_data, input, font_cache): ToolActionHandlerData, + (document, _document_id, _global_tool_data, input, font_cache): ToolActionHandlerData, _tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index 8bc4e0e0b..03960a655 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -105,7 +105,7 @@ impl Fsm for FillToolFsmState { self, event: ToolMessage, _tool_data: &mut Self::ToolData, - (document, global_tool_data, input, font_cache): ToolActionHandlerData, + (document, _document_id, global_tool_data, input, font_cache): ToolActionHandlerData, _tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { diff --git a/editor/src/messages/tool/tool_messages/freehand_tool.rs b/editor/src/messages/tool/tool_messages/freehand_tool.rs index ecb5f2970..740f33b9a 100644 --- a/editor/src/messages/tool/tool_messages/freehand_tool.rs +++ b/editor/src/messages/tool/tool_messages/freehand_tool.rs @@ -161,7 +161,7 @@ impl Fsm for FreehandToolFsmState { self, event: ToolMessage, tool_data: &mut Self::ToolData, - (document, global_tool_data, input, _font_cache): ToolActionHandlerData, + (document, _document_id, global_tool_data, input, _font_cache): ToolActionHandlerData, tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index 663ae52f3..e3476ef85 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -335,7 +335,7 @@ impl Fsm for GradientToolFsmState { self, event: ToolMessage, tool_data: &mut Self::ToolData, - (document, global_tool_data, input, font_cache): ToolActionHandlerData, + (document, _document_id, global_tool_data, input, font_cache): ToolActionHandlerData, tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { diff --git a/editor/src/messages/tool/tool_messages/imaginate_tool.rs b/editor/src/messages/tool/tool_messages/imaginate_tool.rs new file mode 100644 index 000000000..9816e4dbd --- /dev/null +++ b/editor/src/messages/tool/tool_messages/imaginate_tool.rs @@ -0,0 +1,231 @@ +use crate::consts::DRAG_THRESHOLD; +use crate::messages::frontend::utility_types::MouseCursorIcon; +use crate::messages::input_mapper::utility_types::input_keyboard::{Key, KeysGroup, MouseMotion}; +use crate::messages::layout::utility_types::layout_widget::PropertyHolder; +use crate::messages::prelude::*; +use crate::messages::tool::common_functionality::resize::Resize; +use crate::messages::tool::utility_types::{EventToMessageMap, Fsm, ToolActionHandlerData, ToolMetadata, ToolTransition, ToolType}; +use crate::messages::tool::utility_types::{HintData, HintGroup, HintInfo}; + +use graphene::Operation; + +use glam::DAffine2; +use serde::{Deserialize, Serialize}; + +#[derive(Default)] +pub struct ImaginateTool { + fsm_state: ImaginateToolFsmState, + tool_data: ImaginateToolData, +} + +#[remain::sorted] +#[impl_message(Message, ToolMessage, Imaginate)] +#[derive(PartialEq, Eq, Clone, Debug, Hash, Serialize, Deserialize)] +pub enum ImaginateToolMessage { + // Standard messages + #[remain::unsorted] + Abort, + + // Tool-specific messages + DragStart, + DragStop, + Resize { + center: Key, + lock_ratio: Key, + }, +} + +impl PropertyHolder for ImaginateTool {} + +impl<'a> MessageHandler> for ImaginateTool { + fn process_message(&mut self, message: ToolMessage, tool_data: ToolActionHandlerData<'a>, responses: &mut VecDeque) { + if message == ToolMessage::UpdateHints { + self.fsm_state.update_hints(responses); + return; + } + + if message == ToolMessage::UpdateCursor { + self.fsm_state.update_cursor(responses); + return; + } + + let new_state = self.fsm_state.transition(message, &mut self.tool_data, tool_data, &(), responses); + + if self.fsm_state != new_state { + self.fsm_state = new_state; + self.fsm_state.update_hints(responses); + self.fsm_state.update_cursor(responses); + } + } + + fn actions(&self) -> ActionList { + use ImaginateToolFsmState::*; + + match self.fsm_state { + Ready => actions!(ImaginateToolMessageDiscriminant; + DragStart, + ), + Drawing => actions!(ImaginateToolMessageDiscriminant; + DragStop, + Abort, + Resize, + ), + } + } +} + +impl ToolMetadata for ImaginateTool { + fn icon_name(&self) -> String { + "RasterImaginateTool".into() + } + fn tooltip(&self) -> String { + "Imaginate Tool".into() + } + fn tool_type(&self) -> crate::messages::tool::utility_types::ToolType { + ToolType::Imaginate + } +} + +impl ToolTransition for ImaginateTool { + fn event_to_message_map(&self) -> EventToMessageMap { + EventToMessageMap { + document_dirty: None, + tool_abort: Some(ImaginateToolMessage::Abort.into()), + selection_changed: None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ImaginateToolFsmState { + Ready, + Drawing, +} + +impl Default for ImaginateToolFsmState { + fn default() -> Self { + ImaginateToolFsmState::Ready + } +} +#[derive(Clone, Debug, Default)] +struct ImaginateToolData { + data: Resize, +} + +impl Fsm for ImaginateToolFsmState { + type ToolData = ImaginateToolData; + type ToolOptions = (); + + fn transition( + self, + event: ToolMessage, + tool_data: &mut Self::ToolData, + (document, _document_id, _global_tool_data, input, font_cache): ToolActionHandlerData, + _tool_options: &Self::ToolOptions, + responses: &mut VecDeque, + ) -> Self { + use ImaginateToolFsmState::*; + use ImaginateToolMessage::*; + + let mut shape_data = &mut tool_data.data; + + if let ToolMessage::Imaginate(event) = event { + match (self, event) { + (Ready, DragStart) => { + shape_data.start(responses, document, input.mouse.position, font_cache); + responses.push_back(DocumentMessage::StartTransaction.into()); + shape_data.path = Some(document.get_path_for_new_layer()); + responses.push_back(DocumentMessage::DeselectAllLayers.into()); + + responses.push_back( + Operation::AddImaginateFrame { + path: shape_data.path.clone().unwrap(), + insert_index: -1, + transform: DAffine2::ZERO.to_cols_array(), + } + .into(), + ); + + Drawing + } + (state, Resize { center, lock_ratio }) => { + if let Some(message) = shape_data.calculate_transform(responses, document, center, lock_ratio, input) { + responses.push_back(message); + } + + state + } + (Drawing, DragStop) => { + match shape_data.drag_start.distance(input.mouse.position) <= DRAG_THRESHOLD { + true => responses.push_back(DocumentMessage::AbortTransaction.into()), + false => responses.push_back(DocumentMessage::CommitTransaction.into()), + } + + shape_data.cleanup(responses); + + Ready + } + (Drawing, Abort) => { + responses.push_back(DocumentMessage::AbortTransaction.into()); + + shape_data.cleanup(responses); + + Ready + } + _ => self, + } + } else { + self + } + } + + fn update_hints(&self, responses: &mut VecDeque) { + let hint_data = match self { + ImaginateToolFsmState::Ready => HintData(vec![HintGroup(vec![ + HintInfo { + key_groups: vec![], + key_groups_mac: None, + mouse: Some(MouseMotion::LmbDrag), + label: String::from("Draw Repaint Frame"), + plus: false, + }, + HintInfo { + key_groups: vec![KeysGroup(vec![Key::Shift])], + key_groups_mac: None, + mouse: None, + label: String::from("Constrain Square"), + plus: true, + }, + HintInfo { + key_groups: vec![KeysGroup(vec![Key::Alt])], + key_groups_mac: None, + mouse: None, + label: String::from("From Center"), + plus: true, + }, + ])]), + ImaginateToolFsmState::Drawing => HintData(vec![HintGroup(vec![ + HintInfo { + key_groups: vec![KeysGroup(vec![Key::Shift])], + key_groups_mac: None, + mouse: None, + label: String::from("Constrain Square"), + plus: false, + }, + HintInfo { + key_groups: vec![KeysGroup(vec![Key::Alt])], + key_groups_mac: None, + mouse: None, + label: String::from("From Center"), + plus: false, + }, + ])]), + }; + + responses.push_back(FrontendMessage::UpdateInputHints { hint_data }.into()); + } + + fn update_cursor(&self, responses: &mut VecDeque) { + responses.push_back(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Crosshair }.into()); + } +} diff --git a/editor/src/messages/tool/tool_messages/line_tool.rs b/editor/src/messages/tool/tool_messages/line_tool.rs index d79b5028b..90c30c8f6 100644 --- a/editor/src/messages/tool/tool_messages/line_tool.rs +++ b/editor/src/messages/tool/tool_messages/line_tool.rs @@ -170,7 +170,7 @@ impl Fsm for LineToolFsmState { self, event: ToolMessage, tool_data: &mut Self::ToolData, - (document, global_tool_data, input, font_cache): ToolActionHandlerData, + (document, _document_id, global_tool_data, input, font_cache): ToolActionHandlerData, tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { diff --git a/editor/src/messages/tool/tool_messages/mod.rs b/editor/src/messages/tool/tool_messages/mod.rs index b036cca5b..5b6934343 100644 --- a/editor/src/messages/tool/tool_messages/mod.rs +++ b/editor/src/messages/tool/tool_messages/mod.rs @@ -4,6 +4,7 @@ pub mod eyedropper_tool; pub mod fill_tool; pub mod freehand_tool; pub mod gradient_tool; +pub mod imaginate_tool; pub mod line_tool; pub mod navigate_tool; pub mod path_tool; diff --git a/editor/src/messages/tool/tool_messages/navigate_tool.rs b/editor/src/messages/tool/tool_messages/navigate_tool.rs index 3ace685e6..a90294f8b 100644 --- a/editor/src/messages/tool/tool_messages/navigate_tool.rs +++ b/editor/src/messages/tool/tool_messages/navigate_tool.rs @@ -126,7 +126,7 @@ impl Fsm for NavigateToolFsmState { self, message: ToolMessage, tool_data: &mut Self::ToolData, - (_document, _global_tool_data, input, _font_cache): ToolActionHandlerData, + (_document, _document_id, _global_tool_data, input, _font_cache): ToolActionHandlerData, _tool_options: &Self::ToolOptions, messages: &mut VecDeque, ) -> Self { diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index f6845d6a6..2f72f1204 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -142,7 +142,7 @@ impl Fsm for PathToolFsmState { self, event: ToolMessage, tool_data: &mut Self::ToolData, - (document, _global_tool_data, input, font_cache): ToolActionHandlerData, + (document, _document_id, _global_tool_data, input, font_cache): ToolActionHandlerData, _tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { diff --git a/editor/src/messages/tool/tool_messages/pen_tool.rs b/editor/src/messages/tool/tool_messages/pen_tool.rs index fdf68d946..7b82000f1 100644 --- a/editor/src/messages/tool/tool_messages/pen_tool.rs +++ b/editor/src/messages/tool/tool_messages/pen_tool.rs @@ -182,7 +182,7 @@ impl Fsm for PenToolFsmState { self, event: ToolMessage, tool_data: &mut Self::ToolData, - (document, global_tool_data, input, font_cache): ToolActionHandlerData, + (document, _document_id, global_tool_data, input, font_cache): ToolActionHandlerData, tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { diff --git a/editor/src/messages/tool/tool_messages/rectangle_tool.rs b/editor/src/messages/tool/tool_messages/rectangle_tool.rs index a23fa57ed..c526d48bd 100644 --- a/editor/src/messages/tool/tool_messages/rectangle_tool.rs +++ b/editor/src/messages/tool/tool_messages/rectangle_tool.rs @@ -121,7 +121,7 @@ impl Fsm for RectangleToolFsmState { self, event: ToolMessage, tool_data: &mut Self::ToolData, - (document, global_tool_data, input, font_cache): ToolActionHandlerData, + (document, _document_id, global_tool_data, input, font_cache): ToolActionHandlerData, _tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index ae12e4538..60b881405 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -1,6 +1,6 @@ use crate::application::generate_uuid; use crate::consts::{ROTATE_SNAP_ANGLE, SELECTION_TOLERANCE}; -use crate::messages::frontend::utility_types::MouseCursorIcon; +use crate::messages::frontend::utility_types::{FrontendImageData, MouseCursorIcon}; use crate::messages::input_mapper::utility_types::input_keyboard::{Key, KeysGroup, MouseMotion}; use crate::messages::input_mapper::utility_types::input_mouse::ViewportPosition; use crate::messages::layout::utility_types::layout_widget::{Layout, LayoutGroup, PropertyHolder, Widget, WidgetCallback, WidgetHolder, WidgetLayout}; @@ -374,7 +374,7 @@ impl Fsm for SelectToolFsmState { self, event: ToolMessage, tool_data: &mut Self::ToolData, - (document, _global_tool_data, input, font_cache): ToolActionHandlerData, + (document, document_id, _global_tool_data, input, font_cache): ToolActionHandlerData, _tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { @@ -550,12 +550,6 @@ impl Fsm for SelectToolFsmState { .flat_map(snapping::expand_bounds) .collect(); - if input.keyboard.get(duplicate as usize) && tool_data.not_duplicated_layers.is_none() { - tool_data.start_duplicates(document, responses); - } else if !input.keyboard.get(duplicate as usize) && tool_data.not_duplicated_layers.is_some() { - tool_data.stop_duplicates(responses); - } - let closest_move = tool_data.snap_manager.snap_layers(responses, document, snap, mouse_delta); // TODO: Cache the result of `shallowest_unique_layers` to avoid this heavy computation every frame of movement, see https://github.com/GraphiteEditor/Graphite/pull/481 for path in Document::shallowest_unique_layers(tool_data.layers_dragging.iter()) { @@ -568,6 +562,13 @@ impl Fsm for SelectToolFsmState { ); } tool_data.drag_current = mouse_position + closest_move; + + if input.keyboard.get(duplicate as usize) && tool_data.not_duplicated_layers.is_none() { + tool_data.start_duplicates(document, document_id, responses); + } else if !input.keyboard.get(duplicate as usize) && tool_data.not_duplicated_layers.is_some() { + tool_data.stop_duplicates(responses); + } + Dragging } (ResizingBounds, PointerMove { axis_align, center, .. }) => { @@ -919,8 +920,8 @@ impl Fsm for SelectToolFsmState { } impl SelectToolData { - /// Duplicates the currently dragging layers. Called when alt is pressed and the layers have not yet been duplicated. - fn start_duplicates(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque) { + /// Duplicates the currently dragging layers. Called when Alt is pressed and the layers have not yet been duplicated. + fn start_duplicates(&mut self, document: &DocumentMessageHandler, document_id: u64, responses: &mut VecDeque) { responses.push_back(DocumentMessage::DeselectAllLayers.into()); self.not_duplicated_layers = Some(self.layers_dragging.clone()); @@ -938,19 +939,34 @@ impl SelectToolData { // Copy the layers. // Not using the Copy message allows us to retrieve the ids of the new layers to initialize the drag. - let layer = match document.graphene_document.layer(layer_path) { + let mut layer = match document.graphene_document.layer(layer_path) { Ok(layer) => layer.clone(), Err(e) => { warn!("Could not access selected layer {:?}: {:?}", layer_path, e); continue; } }; + let layer_metadata = *document.layer_metadata(layer_path); *layer_path.last_mut().unwrap() = generate_uuid(); + let image_data = if let LayerDataType::Imaginate(imaginate) = &mut layer.data { + imaginate.blob_url = None; + + imaginate.image_data.as_ref().map(|data| { + vec![FrontendImageData { + path: layer_path.clone(), + image_data: data.image_data.clone(), + mime: imaginate.mime.clone(), + }] + }) + } else { + None + }; + responses.push_back( Operation::InsertLayer { - layer, + layer: Box::new(layer), destination_path: layer_path.clone(), insert_index: -1, } @@ -964,10 +980,14 @@ impl SelectToolData { } .into(), ); + + if let Some(image_data) = image_data { + responses.push_back(FrontendMessage::UpdateImageData { image_data, document_id }.into()); + } } } - /// Removes the duplicated layers. Called when alt is released and the layers have been duplicated. + /// Removes the duplicated layers. Called when Alt is released and the layers have been duplicated. fn stop_duplicates(&mut self, responses: &mut VecDeque) { let originals = match self.not_duplicated_layers.take() { Some(x) => x, diff --git a/editor/src/messages/tool/tool_messages/shape_tool.rs b/editor/src/messages/tool/tool_messages/shape_tool.rs index e7e5c559e..7579322ce 100644 --- a/editor/src/messages/tool/tool_messages/shape_tool.rs +++ b/editor/src/messages/tool/tool_messages/shape_tool.rs @@ -162,7 +162,7 @@ impl Fsm for ShapeToolFsmState { self, event: ToolMessage, tool_data: &mut Self::ToolData, - (document, global_tool_data, input, font_cache): ToolActionHandlerData, + (document, _document_id, global_tool_data, input, font_cache): ToolActionHandlerData, tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { diff --git a/editor/src/messages/tool/tool_messages/spline_tool.rs b/editor/src/messages/tool/tool_messages/spline_tool.rs index 6907ecdb8..0211380ef 100644 --- a/editor/src/messages/tool/tool_messages/spline_tool.rs +++ b/editor/src/messages/tool/tool_messages/spline_tool.rs @@ -170,7 +170,7 @@ impl Fsm for SplineToolFsmState { self, event: ToolMessage, tool_data: &mut Self::ToolData, - (document, global_tool_data, input, font_cache): ToolActionHandlerData, + (document, _document_id, global_tool_data, input, font_cache): ToolActionHandlerData, tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index 294d7d3d2..ba182c9d8 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -274,7 +274,7 @@ impl Fsm for TextToolFsmState { self, event: ToolMessage, tool_data: &mut Self::ToolData, - (document, global_tool_data, input, font_cache): ToolActionHandlerData, + (document, _document_id, global_tool_data, input, font_cache): ToolActionHandlerData, tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { @@ -302,7 +302,7 @@ impl Fsm for TextToolFsmState { { if state == TextToolFsmState::Editing { responses.push_back( - DocumentMessage::SetTexboxEditability { + DocumentMessage::SetTextboxEditability { path: tool_data.path.clone(), editable: false, } @@ -313,7 +313,7 @@ impl Fsm for TextToolFsmState { tool_data.path = l.clone(); responses.push_back( - DocumentMessage::SetTexboxEditability { + DocumentMessage::SetTextboxEditability { path: tool_data.path.clone(), editable: true, } @@ -358,7 +358,7 @@ impl Fsm for TextToolFsmState { ); responses.push_back( - DocumentMessage::SetTexboxEditability { + DocumentMessage::SetTextboxEditability { path: tool_data.path.clone(), editable: true, } @@ -376,7 +376,7 @@ impl Fsm for TextToolFsmState { } else { // Removing old text as editable responses.push_back( - DocumentMessage::SetTexboxEditability { + DocumentMessage::SetTextboxEditability { path: tool_data.path.clone(), editable: false, } @@ -393,7 +393,7 @@ impl Fsm for TextToolFsmState { (state, Abort) => { if state == TextToolFsmState::Editing { responses.push_back( - DocumentMessage::SetTexboxEditability { + DocumentMessage::SetTextboxEditability { path: tool_data.path.clone(), editable: false, } @@ -420,7 +420,7 @@ impl Fsm for TextToolFsmState { ); responses.push_back( - DocumentMessage::SetTexboxEditability { + DocumentMessage::SetTextboxEditability { path: tool_data.path.clone(), editable: false, } diff --git a/editor/src/messages/tool/utility_types.rs b/editor/src/messages/tool/utility_types.rs index 57a76a802..84ad2bb9e 100644 --- a/editor/src/messages/tool/utility_types.rs +++ b/editor/src/messages/tool/utility_types.rs @@ -16,7 +16,7 @@ use graphene::layers::text_layer::FontCache; use serde::{Deserialize, Serialize}; use std::fmt::{self, Debug}; -pub type ToolActionHandlerData<'a> = (&'a DocumentMessageHandler, &'a DocumentToolData, &'a InputPreprocessorMessageHandler, &'a FontCache); +pub type ToolActionHandlerData<'a> = (&'a DocumentMessageHandler, u64, &'a DocumentToolData, &'a InputPreprocessorMessageHandler, &'a FontCache); pub trait ToolCommon: for<'a> MessageHandler> + PropertyHolder + ToolTransition + ToolMetadata {} impl ToolCommon for T where T: for<'a> MessageHandler> + PropertyHolder + ToolTransition + ToolMetadata {} @@ -161,13 +161,17 @@ impl PropertyHolder for ToolData { fn properties(&self) -> Layout { let tool_groups_layout = list_tools_in_groups() .iter() - .map(|tool_group| tool_group.iter().map(|tool| ToolEntry { - tooltip: tool.tooltip(), - tooltip_shortcut: action_keys!(tool_type_to_activate_tool_message(tool.tool_type())), - icon_name: tool.icon_name(), - tool_type: tool.tool_type(), + .map(|tool_group| tool_group.iter().map(|tool_availability| { + match tool_availability { + ToolAvailability::Available(tool) => ToolEntry { + tooltip: tool.tooltip(), + tooltip_shortcut: action_keys!(tool_type_to_activate_tool_message(tool.tool_type())), + icon_name: tool.icon_name(), + tool_type: tool.tool_type(), + }, + ToolAvailability::ComingSoon(tool) => tool.clone(), + } }).collect::>()) - .chain(coming_soon_tools()) .flat_map(|group| { let separator = std::iter::once(WidgetHolder::new(Widget::Separator(Separator { direction: SeparatorDirection::Vertical, @@ -189,6 +193,7 @@ impl PropertyHolder for ToolData { }), })) }); + separator.chain(buttons) }) // Skip the initial separator @@ -201,7 +206,7 @@ impl PropertyHolder for ToolData { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ToolEntry { pub tooltip: String, pub tooltip_shortcut: Option, @@ -220,7 +225,14 @@ impl Default for ToolFsmState { ToolFsmState { tool_data: ToolData { active_tool_type: ToolType::Select, - tools: list_tools_in_groups().into_iter().flatten().map(|tool| (tool.tool_type(), tool)).collect(), + tools: list_tools_in_groups() + .into_iter() + .flatten() + .filter_map(|tool| match tool { + ToolAvailability::Available(tool) => Some((tool.tool_type(), tool)), + ToolAvailability::ComingSoon(_) => None, + }) + .collect(), }, document_tool_data: DocumentToolData { primary_color: Color::BLACK, @@ -265,76 +277,81 @@ pub enum ToolType { Patch, Detail, Relight, + Imaginate, +} + +enum ToolAvailability { + Available(Box), + ComingSoon(ToolEntry), } /// List of all the tools in their conventional ordering and grouping. -pub fn list_tools_in_groups() -> Vec>> { +fn list_tools_in_groups() -> Vec> { vec![ vec![ // General tool group - Box::new(select_tool::SelectTool::default()), - Box::new(artboard_tool::ArtboardTool::default()), - Box::new(navigate_tool::NavigateTool::default()), - Box::new(eyedropper_tool::EyedropperTool::default()), - Box::new(fill_tool::FillTool::default()), - Box::new(gradient_tool::GradientTool::default()), + ToolAvailability::Available(Box::new(select_tool::SelectTool::default())), + ToolAvailability::Available(Box::new(artboard_tool::ArtboardTool::default())), + ToolAvailability::Available(Box::new(navigate_tool::NavigateTool::default())), + ToolAvailability::Available(Box::new(eyedropper_tool::EyedropperTool::default())), + ToolAvailability::Available(Box::new(fill_tool::FillTool::default())), + ToolAvailability::Available(Box::new(gradient_tool::GradientTool::default())), ], vec![ // Vector tool group - Box::new(path_tool::PathTool::default()), - Box::new(pen_tool::PenTool::default()), - Box::new(freehand_tool::FreehandTool::default()), - Box::new(spline_tool::SplineTool::default()), - Box::new(line_tool::LineTool::default()), - Box::new(rectangle_tool::RectangleTool::default()), - Box::new(ellipse_tool::EllipseTool::default()), - Box::new(shape_tool::ShapeTool::default()), - Box::new(text_tool::TextTool::default()), + ToolAvailability::Available(Box::new(path_tool::PathTool::default())), + ToolAvailability::Available(Box::new(pen_tool::PenTool::default())), + ToolAvailability::Available(Box::new(freehand_tool::FreehandTool::default())), + ToolAvailability::Available(Box::new(spline_tool::SplineTool::default())), + ToolAvailability::Available(Box::new(line_tool::LineTool::default())), + ToolAvailability::Available(Box::new(rectangle_tool::RectangleTool::default())), + ToolAvailability::Available(Box::new(ellipse_tool::EllipseTool::default())), + ToolAvailability::Available(Box::new(shape_tool::ShapeTool::default())), + ToolAvailability::Available(Box::new(text_tool::TextTool::default())), + ], + vec![ + // Raster tool group + ToolAvailability::Available(Box::new(imaginate_tool::ImaginateTool::default())), + ToolAvailability::ComingSoon(ToolEntry { + tool_type: ToolType::Brush, + icon_name: "RasterBrushTool".into(), + tooltip: "Coming Soon: Brush Tool (B)".into(), + tooltip_shortcut: None, + }), + ToolAvailability::ComingSoon(ToolEntry { + tool_type: ToolType::Heal, + icon_name: "RasterHealTool".into(), + tooltip: "Coming Soon: Heal Tool (J)".into(), + tooltip_shortcut: None, + }), + ToolAvailability::ComingSoon(ToolEntry { + tool_type: ToolType::Clone, + icon_name: "RasterCloneTool".into(), + tooltip: "Coming Soon: Clone Tool (C)".into(), + tooltip_shortcut: None, + }), + ToolAvailability::ComingSoon(ToolEntry { + tool_type: ToolType::Patch, + icon_name: "RasterPatchTool".into(), + tooltip: "Coming Soon: Patch Tool".into(), + tooltip_shortcut: None, + }), + ToolAvailability::ComingSoon(ToolEntry { + tool_type: ToolType::Detail, + icon_name: "RasterDetailTool".into(), + tooltip: "Coming Soon: Detail Tool (D)".into(), + tooltip_shortcut: None, + }), + ToolAvailability::ComingSoon(ToolEntry { + tool_type: ToolType::Relight, + icon_name: "RasterRelightTool".into(), + tooltip: "Coming Soon: Relight Tool (O)".into(), + tooltip_shortcut: None, + }), ], ] } -pub fn coming_soon_tools() -> Vec> { - vec![vec![ - ToolEntry { - tool_type: ToolType::Brush, - icon_name: "RasterBrushTool".into(), - tooltip: "Coming Soon: Brush Tool (B)".into(), - tooltip_shortcut: None, - }, - ToolEntry { - tool_type: ToolType::Heal, - icon_name: "RasterHealTool".into(), - tooltip: "Coming Soon: Heal Tool (J)".into(), - tooltip_shortcut: None, - }, - ToolEntry { - tool_type: ToolType::Clone, - icon_name: "RasterCloneTool".into(), - tooltip: "Coming Soon: Clone Tool (C)".into(), - tooltip_shortcut: None, - }, - ToolEntry { - tool_type: ToolType::Patch, - icon_name: "RasterPatchTool".into(), - tooltip: "Coming Soon: Patch Tool".into(), - tooltip_shortcut: None, - }, - ToolEntry { - tool_type: ToolType::Detail, - icon_name: "RasterDetailTool".into(), - tooltip: "Coming Soon: Detail Tool (D)".into(), - tooltip_shortcut: None, - }, - ToolEntry { - tool_type: ToolType::Relight, - icon_name: "RasterRelightTool".into(), - tooltip: "Coming Soon: Relight Tool (O)".into(), - tooltip_shortcut: None, - }, - ]] -} - pub fn tool_message_to_tool_type(tool_message: &ToolMessage) -> ToolType { match tool_message { // General tool group @@ -363,6 +380,7 @@ pub fn tool_message_to_tool_type(tool_message: &ToolMessage) -> ToolType { // ToolMessage::Patch(_) => ToolType::Patch, // ToolMessage::Detail(_) => ToolType::Detail, // ToolMessage::Relight(_) => ToolType::Relight, + ToolMessage::Imaginate(_) => ToolType::Imaginate, _ => panic!( "Conversion from ToolMessage to ToolType impossible because the given ToolMessage does not have a matching ToolType. Got: {:?}", tool_message @@ -398,6 +416,7 @@ pub fn tool_type_to_activate_tool_message(tool_type: ToolType) -> ToolMessageDis // ToolType::Patch => ToolMessageDiscriminant::ActivateToolPatch, // ToolType::Detail => ToolMessageDiscriminant::ActivateToolDetail, // ToolType::Relight => ToolMessageDiscriminant::ActivateToolRelight, + ToolType::Imaginate => ToolMessageDiscriminant::ActivateToolImaginate, _ => panic!( "Conversion from ToolType to ToolMessage impossible because the given ToolType does not have a matching ToolMessage. Got: {:?}", tool_type diff --git a/editor/src/test_utils.rs b/editor/src/test_utils.rs index 7a19cc087..4e6a3f00f 100644 --- a/editor/src/test_utils.rs +++ b/editor/src/test_utils.rs @@ -2,7 +2,7 @@ use crate::application::set_uuid_seed; use crate::application::Editor; use crate::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; use crate::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, MouseKeys, ScrollDelta, ViewportPosition}; -use crate::messages::portfolio::document::utility_types::misc::Platform; +use crate::messages::portfolio::utility_types::Platform; use crate::messages::prelude::*; use crate::messages::tool::utility_types::ToolType; diff --git a/frontend/assets/graphics/graphite-logotype-solid.svg b/frontend/assets/graphics/graphite-logotype-solid.svg index 466bb496c..55e387d03 100644 --- a/frontend/assets/graphics/graphite-logotype-solid.svg +++ b/frontend/assets/graphics/graphite-logotype-solid.svg @@ -1,11 +1,11 @@ - - - - - - - - - + + + + + + + + + diff --git a/frontend/assets/icon-16px-solid/node-imaginate.svg b/frontend/assets/icon-16px-solid/node-imaginate.svg new file mode 100644 index 000000000..95451b441 --- /dev/null +++ b/frontend/assets/icon-16px-solid/node-imaginate.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/assets/icon-16px-solid/random.svg b/frontend/assets/icon-16px-solid/random.svg new file mode 100644 index 000000000..635cb1b63 --- /dev/null +++ b/frontend/assets/icon-16px-solid/random.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/icon-16px-solid/regenerate.svg b/frontend/assets/icon-16px-solid/regenerate.svg new file mode 100644 index 000000000..62555a17a --- /dev/null +++ b/frontend/assets/icon-16px-solid/regenerate.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/assets/icon-16px-solid/reload.svg b/frontend/assets/icon-16px-solid/reload.svg new file mode 100644 index 000000000..01f83b2a4 --- /dev/null +++ b/frontend/assets/icon-16px-solid/reload.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/assets/icon-16px-solid/rescale.svg b/frontend/assets/icon-16px-solid/rescale.svg new file mode 100644 index 000000000..0f547eeff --- /dev/null +++ b/frontend/assets/icon-16px-solid/rescale.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/assets/icon-16px-solid/reset.svg b/frontend/assets/icon-16px-solid/reset.svg new file mode 100644 index 000000000..fc72ad234 --- /dev/null +++ b/frontend/assets/icon-16px-solid/reset.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/assets/icon-16px-solid/settings.svg b/frontend/assets/icon-16px-solid/settings.svg new file mode 100644 index 000000000..938ea819a --- /dev/null +++ b/frontend/assets/icon-16px-solid/settings.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/assets/icon-24px-two-tone/raster-imaginate-tool.svg b/frontend/assets/icon-24px-two-tone/raster-imaginate-tool.svg new file mode 100644 index 000000000..da47ce268 --- /dev/null +++ b/frontend/assets/icon-24px-two-tone/raster-imaginate-tool.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5e5293509..a1397e0e5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -217,7 +217,6 @@ img { diff --git a/frontend/src/components/widgets/inputs/TextAreaInput.vue b/frontend/src/components/widgets/inputs/TextAreaInput.vue index b7ceec819..49bd1fc9d 100644 --- a/frontend/src/components/widgets/inputs/TextAreaInput.vue +++ b/frontend/src/components/widgets/inputs/TextAreaInput.vue @@ -3,10 +3,11 @@ :textarea="true" class="text-area-input" :class="{ 'has-label': label }" - v-model:value="inputValue" :label="label" :spellcheck="true" :disabled="disabled" + :tooltip="tooltip" + v-model:value="inputValue" @textFocused="() => onTextFocused()" @textChanged="() => onTextChanged()" @cancelTextChange="() => onCancelTextChange()" @@ -27,6 +28,7 @@ export default defineComponent({ value: { type: String as PropType, required: true }, label: { type: String as PropType, required: false }, disabled: { type: Boolean as PropType, default: false }, + tooltip: { type: String as PropType, required: false }, }, data() { return { diff --git a/frontend/src/components/widgets/inputs/TextInput.vue b/frontend/src/components/widgets/inputs/TextInput.vue index 9a1cce5b1..17229400c 100644 --- a/frontend/src/components/widgets/inputs/TextInput.vue +++ b/frontend/src/components/widgets/inputs/TextInput.vue @@ -5,6 +5,8 @@ :label="label" :spellcheck="true" :disabled="disabled" + :tooltip="tooltip" + :style="minWidth > 0 ? `min-width: ${minWidth}px` : ''" @textFocused="() => onTextFocused()" @textChanged="() => onTextChanged()" @cancelTextChange="() => onCancelTextChange()" @@ -31,6 +33,8 @@ export default defineComponent({ value: { type: String as PropType, required: true }, label: { type: String as PropType, required: false }, disabled: { type: Boolean as PropType, default: false }, + minWidth: { type: Number as PropType, default: 0 }, + tooltip: { type: String as PropType, required: false }, }, data() { return { diff --git a/frontend/src/components/widgets/labels/IconLabel.vue b/frontend/src/components/widgets/labels/IconLabel.vue index ffdcbb3bb..e74397fbb 100644 --- a/frontend/src/components/widgets/labels/IconLabel.vue +++ b/frontend/src/components/widgets/labels/IconLabel.vue @@ -1,5 +1,5 @@ @@ -23,35 +23,25 @@ width: 24px; height: 24px; } - - &.node-style { - border-radius: 2px; - background: var(--color-node-background); - fill: var(--color-node-icon); - } } diff --git a/frontend/src/components/window/workspace/Panel.vue b/frontend/src/components/window/workspace/Panel.vue index 11bdf9bdd..457c54d86 100644 --- a/frontend/src/components/window/workspace/Panel.vue +++ b/frontend/src/components/window/workspace/Panel.vue @@ -3,15 +3,16 @@ - {{ tabLabel }} + {{ tabLabel.name }} @@ -31,7 +32,7 @@
- + @@ -39,7 +40,7 @@
- + @@ -244,7 +245,7 @@ export default defineComponent({ props: { tabMinWidths: { type: Boolean as PropType, default: false }, tabCloseButtons: { type: Boolean as PropType, default: false }, - tabLabels: { type: Array as PropType, required: true }, + tabLabels: { type: Array as PropType<{ name: string; tooltip?: string }[]>, required: true }, tabActiveIndex: { type: Number as PropType, required: true }, panelType: { type: String as PropType, required: false }, clickAction: { type: Function as PropType<(index: number) => void>, required: false }, diff --git a/frontend/src/components/window/workspace/Workspace.vue b/frontend/src/components/window/workspace/Workspace.vue index ee77d07ef..9520c0cf7 100644 --- a/frontend/src/components/window/workspace/Workspace.vue +++ b/frontend/src/components/window/workspace/Workspace.vue @@ -7,7 +7,7 @@ :panelType="portfolio.state.documents.length > 0 ? 'Document' : undefined" :tabCloseButtons="true" :tabMinWidths="true" - :tabLabels="portfolio.state.documents.map((doc) => doc.displayName)" + :tabLabels="portfolio.state.documents.map((doc) => ({ name: doc.displayName, tooltip: doc.id }))" :clickAction="(tabIndex: number) => editor.instance.selectDocument(portfolio.state.documents[tabIndex].id)" :closeAction="(tabIndex: number) => editor.instance.closeDocumentWithConfirmation(portfolio.state.documents[tabIndex].id)" :tabActiveIndex="portfolio.state.activeDocumentIndex" @@ -16,17 +16,17 @@ - + - + - + - + diff --git a/frontend/src/io-managers/blob.ts b/frontend/src/io-managers/blob.ts deleted file mode 100644 index eb6871ff0..000000000 --- a/frontend/src/io-managers/blob.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { type Editor } from "@/wasm-communication/editor"; -import { UpdateImageData } from "@/wasm-communication/messages"; - -export function createBlobManager(editor: Editor): void { - // Subscribe to process backend event - editor.subscriptions.subscribeJsMessage(UpdateImageData, (updateImageData) => { - updateImageData.imageData.forEach(async (element) => { - // Using updateImageData.imageData.buffer returns undefined for some reason? - const buffer = new Uint8Array(element.imageData.values()).buffer; - const blob = new Blob([buffer], { type: element.mime }); - - // TODO: Call `URL.revokeObjectURL` at the appropriate time to avoid a memory leak - const blobURL = URL.createObjectURL(blob); - - const image = await createImageBitmap(blob); - - editor.instance.setImageBlobUrl(element.path, blobURL, image.width, image.height); - }); - }); -} diff --git a/frontend/src/io-managers/panic.ts b/frontend/src/io-managers/panic.ts index 8374ea042..c4baba2f8 100644 --- a/frontend/src/io-managers/panic.ts +++ b/frontend/src/io-managers/panic.ts @@ -25,8 +25,8 @@ export function createPanicManager(editor: Editor, dialogState: DialogState): vo function preparePanicDialog(header: string, details: string, panicDetails: string): [IconName, WidgetLayout, TextButtonWidget[]] { const widgets: WidgetLayout = { layout: [ - { rowWidgets: [new Widget({ kind: "TextLabel", value: header, bold: true, italic: false, tableAlign: false, multiline: false }, 0n)] }, - { rowWidgets: [new Widget({ kind: "TextLabel", value: details, bold: false, italic: false, tableAlign: false, multiline: true }, 1n)] }, + { rowWidgets: [new Widget({ kind: "TextLabel", value: header, bold: true, italic: false, tableAlign: false, minWidth: 0, multiline: false, tooltip: "" }, 0n)] }, + { rowWidgets: [new Widget({ kind: "TextLabel", value: details, bold: false, italic: false, tableAlign: false, minWidth: 0, multiline: true, tooltip: "" }, 1n)] }, ], layoutTarget: undefined, }; diff --git a/frontend/src/io-managers/persistence.ts b/frontend/src/io-managers/persistence.ts index 8d6bcf38d..136c65250 100644 --- a/frontend/src/io-managers/persistence.ts +++ b/frontend/src/io-managers/persistence.ts @@ -1,99 +1,155 @@ import { type PortfolioState } from "@/state-providers/portfolio"; import { stripIndents } from "@/utility-functions/strip-indents"; import { type Editor } from "@/wasm-communication/editor"; -import { TriggerIndexedDbWriteDocument, TriggerIndexedDbRemoveDocument } from "@/wasm-communication/messages"; +import { TriggerIndexedDbWriteDocument, TriggerIndexedDbRemoveDocument, TriggerSavePreferences, TriggerLoadAutoSaveDocuments, TriggerLoadPreferences } from "@/wasm-communication/messages"; const GRAPHITE_INDEXED_DB_VERSION = 2; const GRAPHITE_INDEXED_DB_NAME = "graphite-indexed-db"; -const GRAPHITE_AUTO_SAVE_STORE = "auto-save-documents"; + +const GRAPHITE_AUTO_SAVE_STORE = { name: "auto-save-documents", keyPath: "details.id" }; +const GRAPHITE_EDITOR_PREFERENCES_STORE = { name: "editor-preferences", keyPath: "key" }; + +const GRAPHITE_INDEXEDDB_STORES = [GRAPHITE_AUTO_SAVE_STORE, GRAPHITE_EDITOR_PREFERENCES_STORE]; + const GRAPHITE_AUTO_SAVE_ORDER_KEY = "auto-save-documents-order"; -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export async function createPersistenceManager(editor: Editor, portfolio: PortfolioState): Promise<() => void> { - function storeDocumentOrder(): void { - // Make sure to store as string since JSON does not play nice with BigInt - const documentOrder = portfolio.state.documents.map((doc) => doc.id.toString()); - window.localStorage.setItem(GRAPHITE_AUTO_SAVE_ORDER_KEY, JSON.stringify(documentOrder)); - } +export function createPersistenceManager(editor: Editor, portfolio: PortfolioState): () => void { + async function initialize(): Promise { + // Open the IndexedDB database connection and save it to this variable, which is a promise that resolves once the connection is open + return new Promise((resolve) => { + const dbOpenRequest = indexedDB.open(GRAPHITE_INDEXED_DB_NAME, GRAPHITE_INDEXED_DB_VERSION); - async function removeDocument(id: string): Promise { - const db = await databaseConnection; - const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite"); - transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).delete(id); - storeDocumentOrder(); - } + // Handle a version mismatch if `GRAPHITE_INDEXED_DB_VERSION` is now higher than what was saved in the database + dbOpenRequest.onupgradeneeded = (): void => { + const db = dbOpenRequest.result; - async function closeDatabaseConnection(): Promise { - const db = await databaseConnection; - db.close(); - } + // Wipe out all stores when a request is made to upgrade the database version to a newer one + GRAPHITE_INDEXEDDB_STORES.forEach((store) => { + if (db.objectStoreNames.contains(store.name)) db.deleteObjectStore(store.name); - // Subscribe to process backend events - editor.subscriptions.subscribeJsMessage(TriggerIndexedDbWriteDocument, async (autoSaveDocument) => { - const db = await databaseConnection; - const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite"); - transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).put(autoSaveDocument); - storeDocumentOrder(); - }); - editor.subscriptions.subscribeJsMessage(TriggerIndexedDbRemoveDocument, async (removeAutoSaveDocument) => { - removeDocument(removeAutoSaveDocument.documentId); - }); + db.createObjectStore(store.name, { keyPath: store.keyPath }); + }); + }; - // Open the IndexedDB database connection and save it to this variable, which is a promise that resolves once the connection is open - const databaseConnection = new Promise((resolve) => { - const dbOpenRequest = indexedDB.open(GRAPHITE_INDEXED_DB_NAME, GRAPHITE_INDEXED_DB_VERSION); - - dbOpenRequest.onupgradeneeded = (): void => { - const db = dbOpenRequest.result; - // Wipes out all auto-save data on upgrade - if (db.objectStoreNames.contains(GRAPHITE_AUTO_SAVE_STORE)) { - db.deleteObjectStore(GRAPHITE_AUTO_SAVE_STORE); - } - - db.createObjectStore(GRAPHITE_AUTO_SAVE_STORE, { keyPath: "details.id" }); - }; - - dbOpenRequest.onerror = (): void => { - const errorText = stripIndents` + // Handle some other error by presenting it to the user + dbOpenRequest.onerror = (): void => { + const errorText = stripIndents` Documents won't be saved across reloads and later visits. This may be caused by Firefox's private browsing mode. Error on opening IndexDB: ${dbOpenRequest.error} `; - editor.instance.errorDialog("Document auto-save doesn't work in this browser", errorText); - }; + editor.instance.errorDialog("Document auto-save doesn't work in this browser", errorText); + }; - dbOpenRequest.onsuccess = (): void => { - resolve(dbOpenRequest.result); - }; - }); - - databaseConnection.then(async (db) => { - // Open auto-save documents - const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readonly"); - const request = transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).getAll(); - await new Promise((resolve): void => { - request.onsuccess = (): void => { - const previouslySavedDocuments: TriggerIndexedDbWriteDocument[] = request.result; - - const documentOrder: string[] = JSON.parse(window.localStorage.getItem(GRAPHITE_AUTO_SAVE_ORDER_KEY) || "[]"); - const orderedSavedDocuments = documentOrder - .map((id) => previouslySavedDocuments.find((autoSave) => autoSave.details.id === id)) - .filter((x) => x !== undefined) as TriggerIndexedDbWriteDocument[]; - - const currentDocumentVersion = editor.instance.graphiteDocumentVersion(); - orderedSavedDocuments.forEach((doc: TriggerIndexedDbWriteDocument) => { - if (doc.version === currentDocumentVersion) { - editor.instance.openAutoSavedDocument(BigInt(doc.details.id), doc.details.name, doc.details.isSaved, doc.document); - } else { - removeDocument(doc.details.id); - } - }); - resolve(undefined); + // Resolve the promise on a successful opening of the database connection + dbOpenRequest.onsuccess = (): void => { + resolve(dbOpenRequest.result); }; }); + } + + function storeDocumentOrder(): void { + // Make sure to store as string since JSON does not play nice with BigInt + const documentOrder = portfolio.state.documents.map((doc) => doc.id.toString()); + window.localStorage.setItem(GRAPHITE_AUTO_SAVE_ORDER_KEY, JSON.stringify(documentOrder)); + } + + async function removeDocument(id: string, db: IDBDatabase): Promise { + const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE.name, "readwrite"); + transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE.name).delete(id); + storeDocumentOrder(); + } + + async function loadAutoSaveDocuments(db: IDBDatabase): Promise { + let promiseResolve: (value: void | PromiseLike) => void; + const promise = new Promise((resolve): void => { + promiseResolve = resolve; + }); + + // Open auto-save documents + const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE.name, "readonly"); + const request = transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE.name).getAll(); + + request.onsuccess = (): void => { + const previouslySavedDocuments: TriggerIndexedDbWriteDocument[] = request.result; + + const documentOrder: string[] = JSON.parse(window.localStorage.getItem(GRAPHITE_AUTO_SAVE_ORDER_KEY) || "[]"); + const orderedSavedDocuments = documentOrder + .map((id) => previouslySavedDocuments.find((autoSave) => autoSave.details.id === id)) + .filter((x) => x !== undefined) as TriggerIndexedDbWriteDocument[]; + + const currentDocumentVersion = editor.instance.graphiteDocumentVersion(); + orderedSavedDocuments.forEach(async (doc: TriggerIndexedDbWriteDocument) => { + if (doc.version === currentDocumentVersion) { + editor.instance.openAutoSavedDocument(BigInt(doc.details.id), doc.details.name, doc.details.isSaved, doc.document); + } else { + await removeDocument(doc.details.id, db); + } + }); + + promiseResolve(); + }; + + await promise; + } + + async function loadPreferences(db: IDBDatabase): Promise { + let promiseResolve: (value: void | PromiseLike) => void; + const promise = new Promise((resolve): void => { + promiseResolve = resolve; + }); + + // Open auto-save documents + const transaction = db.transaction(GRAPHITE_EDITOR_PREFERENCES_STORE.name, "readonly"); + const request = transaction.objectStore(GRAPHITE_EDITOR_PREFERENCES_STORE.name).getAll(); + + request.onsuccess = (): void => { + const preferenceEntries: { key: string; value: unknown }[] = request.result; + + const preferences: Record = {}; + preferenceEntries.forEach(({ key, value }) => { + preferences[key] = value; + }); + + editor.instance.loadPreferences(JSON.stringify(preferences)); + + promiseResolve(); + }; + + await promise; + } + + // Subscribe to process backend events + editor.subscriptions.subscribeJsMessage(TriggerIndexedDbWriteDocument, async (autoSaveDocument) => { + const transaction = (await databaseConnection).transaction(GRAPHITE_AUTO_SAVE_STORE.name, "readwrite"); + transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE.name).put(autoSaveDocument); + + storeDocumentOrder(); + }); + editor.subscriptions.subscribeJsMessage(TriggerIndexedDbRemoveDocument, async (removeAutoSaveDocument) => { + await removeDocument(removeAutoSaveDocument.documentId, await databaseConnection); + }); + editor.subscriptions.subscribeJsMessage(TriggerLoadAutoSaveDocuments, async () => { + await loadAutoSaveDocuments(await databaseConnection); + }); + editor.subscriptions.subscribeJsMessage(TriggerSavePreferences, async (preferences) => { + Object.entries(preferences.preferences).forEach(async ([key, value]) => { + const storedObject = { key, value }; + + const transaction = (await databaseConnection).transaction(GRAPHITE_EDITOR_PREFERENCES_STORE.name, "readwrite"); + transaction.objectStore(GRAPHITE_EDITOR_PREFERENCES_STORE.name).put(storedObject); + }); + }); + editor.subscriptions.subscribeJsMessage(TriggerLoadPreferences, async () => { + await loadPreferences(await databaseConnection); }); - return closeDatabaseConnection; + const databaseConnection = initialize(); + + // Destructor + return () => { + databaseConnection.then((connection) => connection.close()); + }; } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index dfe599cb2..5a0e6ab08 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -9,7 +9,19 @@ import { initWasm } from "@/wasm-communication/editor"; import App from "@/App.vue"; +// Browser app entry point (async (): Promise => { + // Confirm the browser is compatible before initializing the application + if (!checkBrowserCompatibility()) return; + + // Initialize the WASM module for the editor backend + await initWasm(); + + // Initialize the Vue application + createApp(App).mount("#app"); +})(); + +function checkBrowserCompatibility(): boolean { if (!("BigUint64Array" in window)) { const body = document.body; const message = stripIndents` @@ -23,12 +35,9 @@ import App from "@/App.vue"; JavaScript API must be supported by the browser for Graphite to function.)

`; body.innerHTML = message + body.innerHTML; - return; + + return false; } - // Initialize the WASM module for the editor backend - await initWasm(); - - // Initialize the Vue application - createApp(App).mount("#app"); -})(); + return true; +} diff --git a/frontend/src/state-providers/portfolio.ts b/frontend/src/state-providers/portfolio.ts index f14d756da..b39462a43 100644 --- a/frontend/src/state-providers/portfolio.ts +++ b/frontend/src/state-providers/portfolio.ts @@ -2,6 +2,7 @@ import { reactive, readonly } from "vue"; import { downloadFileText, downloadFileBlob, upload } from "@/utility-functions/files"; +import { imaginateGenerate, imaginateCheckConnection, imaginateTerminate } from "@/utility-functions/imaginate"; import { rasterizeSVG } from "@/utility-functions/rasterization"; import { type Editor } from "@/wasm-communication/editor"; import { @@ -10,8 +11,13 @@ import { TriggerImport, TriggerOpenDocument, TriggerRasterDownload, + TriggerImaginateGenerate, + TriggerImaginateTerminate, + TriggerImaginateCheckServerStatus, UpdateActiveDocument, UpdateOpenDocumentsList, + UpdateImageData, + TriggerRevokeBlobUrl, } from "@/wasm-communication/messages"; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type @@ -55,6 +61,47 @@ export function createPortfolioState(editor: Editor) { // Have the browser download the file to the user's disk downloadFileBlob(name, blob); }); + editor.subscriptions.subscribeJsMessage(TriggerImaginateCheckServerStatus, async (triggerImaginateCheckServerStatus) => { + const { hostname } = triggerImaginateCheckServerStatus; + + imaginateCheckConnection(hostname, editor); + }); + editor.subscriptions.subscribeJsMessage(TriggerImaginateGenerate, async (triggerImaginateGenerate) => { + const { documentId, layerPath, hostname, refreshFrequency, baseImage, parameters } = triggerImaginateGenerate; + + // Handle img2img mode + let image: Blob | undefined; + if (parameters.denoisingStrength !== undefined && baseImage !== undefined) { + // Rasterize the SVG to an image file + image = await rasterizeSVG(baseImage.svg, baseImage.size[0], baseImage.size[1], "image/png"); + + const blobURL = URL.createObjectURL(image); + + editor.instance.setImaginateBlobURL(documentId, layerPath, blobURL, baseImage.size[0], baseImage.size[1]); + } + + imaginateGenerate(parameters, image, hostname, refreshFrequency, documentId, layerPath, editor); + }); + editor.subscriptions.subscribeJsMessage(TriggerImaginateTerminate, async (triggerImaginateTerminate) => { + const { documentId, layerPath, hostname } = triggerImaginateTerminate; + + imaginateTerminate(hostname, documentId, layerPath, editor); + }); + editor.subscriptions.subscribeJsMessage(UpdateImageData, (updateImageData) => { + updateImageData.imageData.forEach(async (element) => { + const buffer = new Uint8Array(element.imageData.values()).buffer; + const blob = new Blob([buffer], { type: element.mime }); + + const blobURL = URL.createObjectURL(blob); + + const image = await createImageBitmap(blob); + + editor.instance.setImageBlobURL(updateImageData.documentId, element.path, blobURL, image.width, image.height); + }); + }); + editor.subscriptions.subscribeJsMessage(TriggerRevokeBlobUrl, async (triggerRevokeBlobUrl) => { + URL.revokeObjectURL(triggerRevokeBlobUrl.url); + }); return { state: readonly(state) as typeof state, diff --git a/frontend/src/utility-functions/escape.ts b/frontend/src/utility-functions/escape.ts new file mode 100644 index 000000000..8284dca0f --- /dev/null +++ b/frontend/src/utility-functions/escape.ts @@ -0,0 +1,14 @@ +/* eslint-disable no-useless-escape */ +/* eslint-disable quotes */ + +export function escapeJSON(str: string): string { + return str + .replace(/[\\]/g, "\\\\") + .replace(/[\"]/g, '\\"') + .replace(/[\/]/g, "\\/") + .replace(/[\b]/g, "\\b") + .replace(/[\f]/g, "\\f") + .replace(/[\n]/g, "\\n") + .replace(/[\r]/g, "\\r") + .replace(/[\t]/g, "\\t"); +} diff --git a/frontend/src/utility-functions/files.ts b/frontend/src/utility-functions/files.ts index fd586874f..f82b6b6e0 100644 --- a/frontend/src/utility-functions/files.ts +++ b/frontend/src/utility-functions/files.ts @@ -52,3 +52,31 @@ export async function upload(acceptedExtensions: stri } export type UploadResult = { filename: string; type: string; content: UploadResultType }; type UploadResultType = T extends "text" ? string : T extends "data" ? Uint8Array : never; + +export function blobToBase64(blob: Blob): Promise { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = (): void => resolve(typeof reader.result === "string" ? reader.result : ""); + reader.readAsDataURL(blob); + }); +} + +export async function replaceBlobURLsWithBase64(svg: string): Promise { + const splitByBlobs = svg.split(/(?<=")(blob:.*?)(?=")/); + const onlyBlobs = splitByBlobs.filter((_, i) => i % 2 === 1); + + const onlyBlobsConverted = onlyBlobs.map(async (blobURL) => { + const data = await fetch(blobURL); + const dataBlob = await data.blob(); + return blobToBase64(dataBlob); + }); + const base64Images = await Promise.all(onlyBlobsConverted); + + const substituted = splitByBlobs.map((segment, i) => { + if (i % 2 === 0) return segment; + + const blobsIndex = Math.floor(i / 2); + return base64Images[blobsIndex]; + }); + return substituted.join(""); +} diff --git a/frontend/src/utility-functions/icons.ts b/frontend/src/utility-functions/icons.ts index 6484033f3..2fd970d61 100644 --- a/frontend/src/utility-functions/icons.ts +++ b/frontend/src/utility-functions/icons.ts @@ -101,6 +101,7 @@ import NodeColorCorrection from "@/../assets/icon-16px-solid/node-color-correcti import NodeFolder from "@/../assets/icon-16px-solid/node-folder.svg"; import NodeGradient from "@/../assets/icon-16px-solid/node-gradient.svg"; import NodeImage from "@/../assets/icon-16px-solid/node-image.svg"; +import NodeImaginate from "@/../assets/icon-16px-solid/node-imaginate.svg"; import NodeMagicWand from "@/../assets/icon-16px-solid/node-magic-wand.svg"; import NodeMask from "@/../assets/icon-16px-solid/node-mask.svg"; import NodeMotionBlur from "@/../assets/icon-16px-solid/node-motion-blur.svg"; @@ -109,6 +110,12 @@ import NodeShape from "@/../assets/icon-16px-solid/node-shape.svg"; import NodeText from "@/../assets/icon-16px-solid/node-text.svg"; import NodeTransform from "@/../assets/icon-16px-solid/node-transform.svg"; import Paste from "@/../assets/icon-16px-solid/paste.svg"; +import Random from "@/../assets/icon-16px-solid/random.svg"; +import Regenerate from "@/../assets/icon-16px-solid/regenerate.svg"; +import Reload from "@/../assets/icon-16px-solid/reload.svg"; +import Rescale from "@/../assets/icon-16px-solid/rescale.svg"; +import Reset from "@/../assets/icon-16px-solid/reset.svg"; +import Settings from "@/../assets/icon-16px-solid/settings.svg"; import Trash from "@/../assets/icon-16px-solid/trash.svg"; import ViewModeNormal from "@/../assets/icon-16px-solid/view-mode-normal.svg"; import ViewModeOutline from "@/../assets/icon-16px-solid/view-mode-outline.svg"; @@ -142,6 +149,7 @@ const SOLID_16PX = { FlipVertical: { component: FlipVertical, size: 16 }, Folder: { component: Folder, size: 16 }, GraphiteLogo: { component: GraphiteLogo, size: 16 }, + NodeImaginate: { component: NodeImaginate, size: 16 }, NodeArtboard: { component: NodeArtboard, size: 16 }, NodeBlur: { component: NodeBlur, size: 16 }, NodeBrushwork: { component: NodeBrushwork, size: 16 }, @@ -157,6 +165,12 @@ const SOLID_16PX = { NodeText: { component: NodeText, size: 16 }, NodeTransform: { component: NodeTransform, size: 16 }, Paste: { component: Paste, size: 16 }, + Random: { component: Random, size: 16 }, + Regenerate: { component: Regenerate, size: 16 }, + Reload: { component: Reload, size: 16 }, + Rescale: { component: Rescale, size: 16 }, + Reset: { component: Reset, size: 16 }, + Settings: { component: Settings, size: 16 }, Trash: { component: Trash, size: 16 }, ViewModeNormal: { component: ViewModeNormal, size: 16 }, ViewModeOutline: { component: ViewModeOutline, size: 16 }, @@ -205,6 +219,7 @@ import RasterBrushTool from "@/../assets/icon-24px-two-tone/raster-brush-tool.sv import RasterCloneTool from "@/../assets/icon-24px-two-tone/raster-clone-tool.svg"; import RasterDetailTool from "@/../assets/icon-24px-two-tone/raster-detail-tool.svg"; import RasterHealTool from "@/../assets/icon-24px-two-tone/raster-heal-tool.svg"; +import RasterImaginateTool from "@/../assets/icon-24px-two-tone/raster-imaginate-tool.svg"; import RasterPatchTool from "@/../assets/icon-24px-two-tone/raster-patch-tool.svg"; import RasterRelightTool from "@/../assets/icon-24px-two-tone/raster-relight-tool.svg"; import VectorEllipseTool from "@/../assets/icon-24px-two-tone/vector-ellipse-tool.svg"; @@ -220,10 +235,11 @@ import VectorTextTool from "@/../assets/icon-24px-two-tone/vector-text-tool.svg" const TWO_TONE_24PX = { GeneralArtboardTool: { component: GeneralArtboardTool, size: 24 }, GeneralEyedropperTool: { component: GeneralEyedropperTool, size: 24 }, - GeneralNavigateTool: { component: GeneralNavigateTool, size: 24 }, - GeneralSelectTool: { component: GeneralSelectTool, size: 24 }, GeneralFillTool: { component: GeneralFillTool, size: 24 }, GeneralGradientTool: { component: GeneralGradientTool, size: 24 }, + GeneralNavigateTool: { component: GeneralNavigateTool, size: 24 }, + GeneralSelectTool: { component: GeneralSelectTool, size: 24 }, + RasterImaginateTool: { component: RasterImaginateTool, size: 24 }, RasterBrushTool: { component: RasterBrushTool, size: 24 }, RasterCloneTool: { component: RasterCloneTool, size: 24 }, RasterDetailTool: { component: RasterDetailTool, size: 24 }, @@ -256,7 +272,6 @@ export const ICON_COMPONENTS = Object.fromEntries(Object.entries(ICONS).map(([na export type IconName = keyof typeof ICONS; export type IconSize = undefined | 12 | 16 | 24 | 32; -export type IconStyle = "Normal" | "Node"; // The following helper type declarations allow us to avoid manually maintaining the `IconName` type declaration as a string union paralleling the keys of the // icon definitions. It lets TypeScript do that for us. Our goal is to define the big key-value pair of icons by constraining its values, but inferring its keys. diff --git a/frontend/src/utility-functions/imaginate.ts b/frontend/src/utility-functions/imaginate.ts new file mode 100644 index 000000000..ae1924381 --- /dev/null +++ b/frontend/src/utility-functions/imaginate.ts @@ -0,0 +1,439 @@ +import { escapeJSON } from "@/utility-functions/escape"; +import { blobToBase64 } from "@/utility-functions/files"; +import { type RequestResult, requestWithUploadDownloadProgress } from "@/utility-functions/network"; +import { stripIndents } from "@/utility-functions/strip-indents"; +import { type Editor } from "@/wasm-communication/editor"; +import { type ImaginateGenerationParameters } from "@/wasm-communication/messages"; + +const MAX_POLLING_RETRIES = 4; +const SERVER_STATUS_CHECK_TIMEOUT = 5000; +const SAMPLING_MODES_POLLING_UNSUPPORTED = ["DPM fast", "DPM adaptive"]; + +let timer: NodeJS.Timeout | undefined; +let terminated = false; + +let generatingAbortRequest: XMLHttpRequest | undefined; +let pollingAbortController = new AbortController(); +let statusAbortController = new AbortController(); + +// PUBLICLY CALLABLE FUNCTIONS + +export async function imaginateGenerate( + parameters: ImaginateGenerationParameters, + image: Blob | undefined, + hostname: string, + refreshFrequency: number, + documentId: bigint, + layerPath: BigUint64Array, + editor: Editor +): Promise { + // Ignore a request to generate a new image while another is already being generated + if (generatingAbortRequest !== undefined) return; + + terminated = false; + + // Immediately set the progress to 0% so the backend knows to update its layout + editor.instance.setImaginateGeneratingStatus(documentId, layerPath, 0, "Beginning"); + + // Initiate a request to the computation server + const discloseUploadingProgress = (progress: number): void => { + editor.instance.setImaginateGeneratingStatus(documentId, layerPath, progress * 100, "Uploading"); + }; + const { uploaded, result, xhr } = await generate(discloseUploadingProgress, hostname, image, parameters); + generatingAbortRequest = xhr; + + try { + // Wait until the request is fully uploaded, which could be slow if the img2img source is large and the user is on a slow connection + await uploaded; + editor.instance.setImaginateGeneratingStatus(documentId, layerPath, 0, "Generating"); + + // Begin polling for updates to the in-progress image generation at the specified interval + // Don't poll if the chosen interval is 0, or if the chosen sampling method does not support polling + if (refreshFrequency > 0 && !SAMPLING_MODES_POLLING_UNSUPPORTED.includes(parameters.samplingMethod)) { + const interval = Math.max(refreshFrequency * 1000, 500); + scheduleNextPollingUpdate(interval, Date.now(), 0, editor, hostname, documentId, layerPath, parameters.resolution); + } + + // Wait for the final image to be returned by the initial request containing either the full image or the last frame if it was terminated by the user + const { body, status } = await result; + if (status < 200 || status > 299) { + throw new Error(`Request to server failed to return a 200-level status code (${status})`); + } + + // Extract the final image from the response and convert it to a data blob + // Highly unstable API + const base64 = JSON.parse(body)?.data[0]?.[0] as string | undefined; + if (typeof base64 !== "string" || !base64.startsWith("data:image/png;base64,")) throw new Error("Could not read final image result from server response"); + const blob = await (await fetch(base64)).blob(); + + // Send the backend an updated status + const percent = terminated ? undefined : 100; + const newStatus = terminated ? "Terminated" : "Idle"; + editor.instance.setImaginateGeneratingStatus(documentId, layerPath, percent, newStatus); + + // Send the backend a blob URL for the final image + const blobURL = URL.createObjectURL(blob); + editor.instance.setImaginateBlobURL(documentId, layerPath, blobURL, parameters.resolution[0], parameters.resolution[1]); + + // Send the backend the blob data to be stored persistently in the layer + const u8Array = new Uint8Array(await blob.arrayBuffer()); + editor.instance.setImaginateImageData(documentId, layerPath, u8Array); + } catch { + editor.instance.setImaginateGeneratingStatus(documentId, layerPath, undefined, "Terminated"); + + await imaginateCheckConnection(hostname, editor); + } + + abortAndResetGenerating(); + abortAndResetPolling(); +} + +export async function imaginateTerminate(hostname: string, documentId: bigint, layerPath: BigUint64Array, editor: Editor): Promise { + terminated = true; + abortAndResetPolling(); + + try { + await terminate(hostname); + + editor.instance.setImaginateGeneratingStatus(documentId, layerPath, undefined, "Terminating"); + } catch { + abortAndResetGenerating(); + abortAndResetPolling(); + + editor.instance.setImaginateGeneratingStatus(documentId, layerPath, undefined, "Terminated"); + + await imaginateCheckConnection(hostname, editor); + } +} + +export async function imaginateCheckConnection(hostname: string, editor: Editor): Promise { + const serverReached = await checkConnection(hostname); + editor.instance.setImaginateServerStatus(serverReached); +} + +// ABORTING AND RESETTING HELPERS + +function abortAndResetGenerating(): void { + generatingAbortRequest?.abort(); + generatingAbortRequest = undefined; +} + +function abortAndResetPolling(): void { + pollingAbortController.abort(); + pollingAbortController = new AbortController(); + clearTimeout(timer); +} + +// POLLING IMPLEMENTATION DETAILS + +function scheduleNextPollingUpdate( + interval: number, + timeoutBegan: number, + pollingRetries: number, + editor: Editor, + hostname: string, + documentId: bigint, + layerPath: BigUint64Array, + resolution: [number, number] +): void { + // Pick a future time that keeps to the user-requested interval if possible, but on slower connections will go as fast as possible without overlapping itself + const nextPollTimeGoal = timeoutBegan + interval; + const timeFromNow = Math.max(0, nextPollTimeGoal - Date.now()); + + timer = setTimeout(async () => { + const nextTimeoutBegan = Date.now(); + + try { + const [blob, percentComplete] = await pollImage(hostname); + if (terminated) return; + + const blobURL = URL.createObjectURL(blob); + editor.instance.setImaginateBlobURL(documentId, layerPath, blobURL, resolution[0], resolution[1]); + editor.instance.setImaginateGeneratingStatus(documentId, layerPath, percentComplete, "Generating"); + + scheduleNextPollingUpdate(interval, nextTimeoutBegan, 0, editor, hostname, documentId, layerPath, resolution); + } catch { + if (generatingAbortRequest === undefined) return; + + if (pollingRetries + 1 > MAX_POLLING_RETRIES) { + abortAndResetGenerating(); + abortAndResetPolling(); + + await imaginateCheckConnection(hostname, editor); + } else { + scheduleNextPollingUpdate(interval, nextTimeoutBegan, pollingRetries + 1, editor, hostname, documentId, layerPath, resolution); + } + } + }, timeFromNow); +} + +// API COMMUNICATION FUNCTIONS +// These are highly unstable APIs that will need to be updated very frequently, so we currently assume usage of this exact commit from the server: +// https://github.com/AUTOMATIC1111/stable-diffusion-webui/commit/7d6042b908c064774ee10961309d396eabdc6c4a + +function endpoint(hostname: string): string { + // Highly unstable API + return `${hostname}api/predict/`; +} + +async function pollImage(hostname: string): Promise<[Blob, number]> { + // Highly unstable API + const result = await fetch(endpoint(hostname), { + signal: pollingAbortController.signal, + headers: { + accept: "*/*", + "accept-language": "en-US,en;q=0.9", + "content-type": "application/json", + }, + referrer: hostname, + referrerPolicy: "strict-origin-when-cross-origin", + body: stripIndents` + { + "fn_index":3, + "data":[], + "session_hash":"0000000000" + }`, + method: "POST", + mode: "cors", + credentials: "omit", + }); + const json = await result.json(); + // Highly unstable API + const percentComplete = Math.abs(Number(json.data[0].match(/(?<="width:).*?(?=%")/)[0])); // The API sometimes returns negative values presumably due to a bug + // Highly unstable API + const base64 = json.data[2]; + + if (typeof base64 !== "string" || !base64.startsWith("data:image/png;base64,")) return Promise.reject(); + + const blob = await (await fetch(base64)).blob(); + + return [blob, percentComplete]; +} + +async function generate( + discloseUploadingProgress: (progress: number) => void, + hostname: string, + image: Blob | undefined, + parameters: ImaginateGenerationParameters +): Promise<{ + uploaded: Promise; + result: Promise; + xhr?: XMLHttpRequest; +}> { + let body; + if (image === undefined || parameters.denoisingStrength === undefined) { + // Highly unstable API + body = stripIndents` + { + "fn_index":13, + "data":[ + "${escapeJSON(parameters.prompt)}", + "${escapeJSON(parameters.negativePrompt)}", + "None", + "None", + ${parameters.samples}, + "${parameters.samplingMethod}", + ${parameters.restoreFaces}, + ${parameters.tiling}, + 1, + 1, + ${parameters.cfgScale}, + ${parameters.seed}, + -1, + 0, + 0, + 0, + false, + ${parameters.resolution[1]}, + ${parameters.resolution[0]}, + false, + 0.7, + 0, + 0, + "None", + false, + false, + null, + "", + "Seed", + "", + "Nothing", + "", + true, + false, + false, + null, + "" + ], + "session_hash":"0000000000" + }`; + } else { + const sourceImageBase64 = await blobToBase64(image); + + // Highly unstable API + body = stripIndents` + { + "fn_index":33, + "data":[ + 0, + "${escapeJSON(parameters.prompt)}", + "${escapeJSON(parameters.negativePrompt)}", + "None", + "None", + "${sourceImageBase64}", + null, + null, + null, + "Draw mask", + ${parameters.samples}, + "${parameters.samplingMethod}", + 4, + "fill", + ${parameters.restoreFaces}, + ${parameters.tiling}, + 1, + 1, + ${parameters.cfgScale}, + ${parameters.denoisingStrength}, + ${parameters.seed}, + -1, + 0, + 0, + 0, + false, + ${parameters.resolution[1]}, + ${parameters.resolution[0]}, + "Just resize", + false, + 32, + "Inpaint masked", + "", + "", + "None", + "", + true, + true, + "", + "", + true, + 50, + true, + 1, + 0, + false, + 4, + 1, + "", + 128, + 8, + ["left","right","up","down"], + 1, + 0.05, + 128, + 4, + "fill", + ["left","right","up","down"], + false, + false, + null, + "", + "", + 64, + "None", + "Seed", + "", + "Nothing", + "", + true, + false, + false, + null, + "", + "" + ], + "session_hash":"0000000000" + }`; + } + + // Prepare a promise that will resolve after the outbound request upload is complete + let uploadedResolve: () => void; + let uploadedReject: () => void; + const uploaded = new Promise((resolve, reject): void => { + uploadedResolve = resolve; + uploadedReject = reject; + }); + + // Fire off the request and, once the outbound request upload is complete, resolve the promise we defined above + const uploadProgress = (progress: number): void => { + if (progress < 1) { + discloseUploadingProgress(progress); + } else { + uploadedResolve(); + } + }; + const [result, xhr] = requestWithUploadDownloadProgress(endpoint(hostname), "POST", body, uploadProgress, abortAndResetPolling); + result.catch(() => uploadedReject()); + + // Return the promise that resolves when the request upload is complete, the promise that resolves when the response download is complete, and the XHR so it can be aborted + return { uploaded, result, xhr }; +} + +async function terminate(hostname: string): Promise { + const body = stripIndents` + { + "fn_index":2, + "data":[], + "session_hash":"0000000000" + }`; + + await fetch(endpoint(hostname), { + headers: { + accept: "*/*", + "accept-language": "en-US,en;q=0.9", + "content-type": "application/json", + }, + referrer: hostname, + referrerPolicy: "strict-origin-when-cross-origin", + body, + method: "POST", + mode: "cors", + credentials: "omit", + }); +} + +async function checkConnection(hostname: string): Promise { + statusAbortController.abort(); + statusAbortController = new AbortController(); + + const timeout = setTimeout(() => statusAbortController.abort(), SERVER_STATUS_CHECK_TIMEOUT); + + const body = stripIndents` + { + "fn_index":100, + "data":[], + "session_hash":"0000000000" + }`; + + try { + await fetch(endpoint(hostname), { + signal: statusAbortController.signal, + headers: { + accept: "*/*", + "accept-language": "en-US,en;q=0.9", + "content-type": "application/json", + }, + referrer: hostname, + referrerPolicy: "strict-origin-when-cross-origin", + body, + method: "POST", + mode: "cors", + credentials: "omit", + }); + + clearTimeout(timeout); + + return true; + } catch (_) { + return false; + } +} diff --git a/frontend/src/utility-functions/network.ts b/frontend/src/utility-functions/network.ts new file mode 100644 index 000000000..701e8a2fc --- /dev/null +++ b/frontend/src/utility-functions/network.ts @@ -0,0 +1,33 @@ +export type RequestResult = { body: string; status: number }; + +// Special implementation using the legacy XMLHttpRequest API that provides callbacks to get: +// - Calls with the percent progress uploading the request to the server +// - Calls when downloading the result from the server, after the server has begun streaming back the response data +// It returns a tuple of the promise as well as the XHR which can be used to call the `.abort()` method on it. +export function requestWithUploadDownloadProgress( + url: string, + method: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH", + body: string, + uploadProgress: (progress: number) => void, + downloadOccurring: () => void +): [Promise, XMLHttpRequest | undefined] { + let xhrValue: XMLHttpRequest | undefined; + const promise = new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.upload.addEventListener("progress", (e) => uploadProgress(e.loaded / e.total)); + xhr.addEventListener("progress", () => downloadOccurring()); + xhr.addEventListener("load", () => resolve({ status: xhr.status, body: xhr.responseText })); + xhr.addEventListener("abort", () => resolve({ status: xhr.status, body: xhr.responseText })); + xhr.addEventListener("error", () => reject(new Error("Request error"))); + xhr.open(method, url, true); + xhr.setRequestHeader("accept", "*/*"); + xhr.setRequestHeader("accept-language", "en-US,en;q=0.9"); + xhr.setRequestHeader("content-type", "application/json"); + + xhrValue = xhr; + + xhr.send(body); + }); + + return [promise, xhrValue]; +} diff --git a/frontend/src/utility-functions/rasterization.ts b/frontend/src/utility-functions/rasterization.ts index f64355b7a..56392d568 100644 --- a/frontend/src/utility-functions/rasterization.ts +++ b/frontend/src/utility-functions/rasterization.ts @@ -1,5 +1,7 @@ +import { replaceBlobURLsWithBase64 } from "@/utility-functions/files"; + // Rasterize the string of an SVG document at a given width and height and turn it into the blob data of an image file matching the given MIME type -export function rasterizeSVG(svg: string, width: number, height: number, mime: string, backgroundColor?: string): Promise { +export async function rasterizeSVG(svg: string, width: number, height: number, mime: string, backgroundColor?: string): Promise { let promiseResolve: (value: Blob | PromiseLike) => void | undefined; let promiseReject: () => void | undefined; const promise = new Promise((resolve, reject) => { @@ -21,9 +23,12 @@ export function rasterizeSVG(svg: string, width: number, height: number, mime: s context.fillRect(0, 0, width, height); } + // This SVG rasterization scheme has the limitation that it cannot access blob URLs, so they must be inlined to base64 URLs + const svgWithBase64Images = await replaceBlobURLsWithBase64(svg); + // Create a blob URL for our SVG const image = new Image(); - const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); + const svgBlob = new Blob([svgWithBase64Images], { type: "image/svg+xml;charset=utf-8" }); const url = URL.createObjectURL(svgBlob); image.onload = (): void => { // Draw our SVG to the canvas diff --git a/frontend/src/wasm-communication/messages.ts b/frontend/src/wasm-communication/messages.ts index 3f13d9851..2b0790cca 100644 --- a/frontend/src/wasm-communication/messages.ts +++ b/frontend/src/wasm-communication/messages.ts @@ -2,7 +2,7 @@ import { Transform, Type, plainToClass } from "class-transformer"; -import { type IconName, type IconSize, type IconStyle } from "@/utility-functions/icons"; +import { type IconName, type IconSize } from "@/utility-functions/icons"; import { type WasmEditorInstance, type WasmRawInstance } from "@/wasm-communication/editor"; import type MenuList from "@/components/floating-menus/MenuList.vue"; @@ -165,7 +165,8 @@ export class UpdateDocumentArtboards extends JsMessage { readonly svg!: string; } -const TupleToVec2 = Transform(({ value }: { value: [number, number] }) => ({ x: value[0], y: value[1] })); +const TupleToVec2 = Transform(({ value }: { value: [number, number] | undefined }) => (value === undefined ? undefined : { x: value[0], y: value[1] })); +const BigIntTupleToNumberTuple = Transform(({ value }: { value: [bigint, bigint] | undefined }) => (value === undefined ? undefined : [Number(value[0]), Number(value[1])])); export type XY = { x: number; y: number }; @@ -215,6 +216,10 @@ export class TriggerFileDownload extends JsMessage { readonly name!: string; } +export class TriggerLoadAutoSaveDocuments extends JsMessage {} + +export class TriggerLoadPreferences extends JsMessage {} + export class TriggerOpenDocument extends JsMessage {} export class TriggerImport extends JsMessage {} @@ -232,8 +237,73 @@ export class TriggerRasterDownload extends JsMessage { readonly size!: XY; } +export class TriggerImaginateCheckServerStatus extends JsMessage { + readonly hostname!: string; +} + +export class TriggerImaginateGenerate extends JsMessage { + @Type(() => ImaginateGenerationParameters) + readonly parameters!: ImaginateGenerationParameters; + + @Type(() => ImaginateBaseImage) + readonly baseImage!: ImaginateBaseImage | undefined; + + readonly hostname!: string; + + readonly refreshFrequency!: number; + + readonly documentId!: bigint; + + readonly layerPath!: BigUint64Array; +} + +export class ImaginateBaseImage { + readonly svg!: string; + + readonly size!: [number, number]; +} + +export class ImaginateGenerationParameters { + readonly seed!: number; + + readonly samples!: number; + + readonly samplingMethod!: string; + + readonly denoisingStrength!: number | undefined; + + readonly cfgScale!: number; + + readonly prompt!: string; + + readonly negativePrompt!: string; + + @BigIntTupleToNumberTuple + readonly resolution!: [number, number]; + + readonly restoreFaces!: boolean; + + readonly tiling!: boolean; +} + +export class TriggerImaginateTerminate extends JsMessage { + readonly documentId!: bigint; + + readonly layerPath!: BigUint64Array; + + readonly hostname!: string; +} + export class TriggerRefreshBoundsOfViewports extends JsMessage {} +export class TriggerRevokeBlobUrl extends JsMessage { + readonly url!: string; +} + +export class TriggerSavePreferences extends JsMessage { + readonly preferences!: Record; +} + export class DocumentChanged extends JsMessage {} export class UpdateDocumentLayerTreeStructure extends JsMessage { @@ -313,8 +383,10 @@ export class DisplayEditableTextbox extends JsMessage { } export class UpdateImageData extends JsMessage { - @Type(() => ImageData) - readonly imageData!: ImageData[]; + readonly documentId!: bigint; + + @Type(() => ImaginateImageData) + readonly imageData!: ImaginateImageData[]; } export class DisplayRemoveEditableTextbox extends JsMessage {} @@ -327,7 +399,8 @@ export class UpdateDocumentLayerDetails extends JsMessage { export class LayerPanelEntry { name!: string; - tooltip!: string; + @Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined)) + tooltip!: string | undefined; visible!: boolean; @@ -348,9 +421,26 @@ export class LayerMetadata { selected!: boolean; } -export type LayerType = "Folder" | "Image" | "Shape" | "Text"; +export type LayerType = "Imaginate" | "Folder" | "Image" | "Shape" | "Text"; -export class ImageData { +export type LayerTypeData = { + name: string; + icon: IconName; +}; + +export function layerTypeData(layerType: LayerType): LayerTypeData | undefined { + const entries: Record = { + Imaginate: { name: "Imaginate", icon: "NodeImaginate" }, + Folder: { name: "Folder", icon: "NodeFolder" }, + Image: { name: "Image", icon: "NodeImage" }, + Shape: { name: "Shape", icon: "NodeShape" }, + Text: { name: "Text", icon: "NodeText" }, + }; + + return entries[layerType]; +} + +export class ImaginateImageData { readonly path!: BigUint64Array; readonly mime!: string; @@ -400,7 +490,8 @@ export class CheckboxInput extends WidgetProps { icon!: IconName; - tooltip!: string; + @Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined)) + tooltip!: string | undefined; } export class ColorInput extends WidgetProps { @@ -412,7 +503,8 @@ export class ColorInput extends WidgetProps { disabled!: boolean; - tooltip!: string; + @Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined)) + tooltip!: string | undefined; } type MenuEntryCommon = { @@ -435,6 +527,7 @@ export type MenuListEntry = MenuEntryCommon & { shortcutRequiresLock?: boolean; value?: string; disabled?: boolean; + tooltip?: string; font?: URL; ref?: InstanceType; }; @@ -449,6 +542,9 @@ export class DropdownInput extends WidgetProps { interactive!: boolean; disabled!: boolean; + + @Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined)) + tooltip!: string | undefined; } export class FontInput extends WidgetProps { @@ -459,6 +555,9 @@ export class FontInput extends WidgetProps { isStyle!: boolean; disabled!: boolean; + + @Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined)) + tooltip!: string | undefined; } export class IconButton extends WidgetProps { @@ -468,13 +567,15 @@ export class IconButton extends WidgetProps { active!: boolean; - tooltip!: string; + @Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined)) + tooltip!: string | undefined; } export class IconLabel extends WidgetProps { icon!: IconName; - iconStyle!: IconStyle | undefined; + @Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined)) + tooltip!: string | undefined; } export type IncrementBehavior = "Add" | "Multiply" | "Callback" | "None"; @@ -501,6 +602,11 @@ export class NumberInput extends WidgetProps { incrementFactor!: number; disabled!: boolean; + + minWidth!: number; + + @Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined)) + tooltip!: string | undefined; } export class OptionalInput extends WidgetProps { @@ -508,7 +614,8 @@ export class OptionalInput extends WidgetProps { icon!: IconName; - tooltip!: string; + @Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined)) + tooltip!: string | undefined; } export class PopoverButton extends WidgetProps { @@ -518,6 +625,9 @@ export class PopoverButton extends WidgetProps { header!: string; text!: string; + + @Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined)) + tooltip!: string | undefined; } export type RadioEntryData = { @@ -560,6 +670,9 @@ export class TextAreaInput extends WidgetProps { label!: string | undefined; disabled!: boolean; + + @Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined)) + tooltip!: string | undefined; } export class TextButton extends WidgetProps { @@ -572,6 +685,9 @@ export class TextButton extends WidgetProps { minWidth!: number; disabled!: boolean; + + @Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined)) + tooltip!: string | undefined; } export type TextButtonWidget = { @@ -585,6 +701,7 @@ export type TextButtonWidget = { emphasized?: boolean; minWidth?: number; disabled?: boolean; + tooltip?: string; // Callbacks // `action` is used via `IconButtonWidget.callback` @@ -597,6 +714,11 @@ export class TextInput extends WidgetProps { label!: string | undefined; disabled!: boolean; + + minWidth!: number; + + @Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined)) + tooltip!: string | undefined; } export class TextLabel extends WidgetProps { @@ -610,7 +732,12 @@ export class TextLabel extends WidgetProps { tableAlign!: boolean; + minWidth!: number; + multiline!: boolean; + + @Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined)) + tooltip!: string | undefined; } export type PivotPosition = "None" | "TopLeft" | "TopCenter" | "TopRight" | "CenterLeft" | "Center" | "CenterRight" | "BottomLeft" | "BottomCenter" | "BottomRight"; @@ -851,15 +978,22 @@ export const messageMakers: Record = { DisplayEditableTextbox, DisplayRemoveEditableTextbox, TriggerAboutGraphiteLocalizedCommitDate, - TriggerOpenDocument, + TriggerImaginateCheckServerStatus, + TriggerImaginateGenerate, + TriggerImaginateTerminate, TriggerFileDownload, TriggerFontLoad, TriggerImport, TriggerIndexedDbRemoveDocument, TriggerIndexedDbWriteDocument, + TriggerLoadAutoSaveDocuments, + TriggerLoadPreferences, + TriggerOpenDocument, TriggerPaste, TriggerRasterDownload, TriggerRefreshBoundsOfViewports, + TriggerRevokeBlobUrl, + TriggerSavePreferences, TriggerTextCommit, TriggerTextCopy, TriggerViewportResize, diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index 80ecfabc6..bedbb345d 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -10,11 +10,11 @@ use editor::application::Editor; use editor::consts::{FILE_SAVE_SUFFIX, GRAPHITE_DOCUMENT_VERSION}; use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta, ViewportBounds}; -use editor::messages::portfolio::document::utility_types::misc::Platform; +use editor::messages::portfolio::utility_types::{ImaginateServerStatus, Platform}; use editor::messages::prelude::*; use graphene::color::Color; +use graphene::layers::imaginate_layer::ImaginateStatus; use graphene::LayerId; -use graphene::Operation; use serde::Serialize; use serde_wasm_bindgen::{self, from_value}; @@ -168,6 +168,13 @@ impl JsEditorHandle { } } + #[wasm_bindgen(js_name = loadPreferences)] + pub fn load_preferences(&self, preferences: String) { + let message = PreferencesMessage::Load { preferences }; + + self.dispatch(message); + } + #[wasm_bindgen(js_name = selectDocument)] pub fn select_document(&self, document_id: u64) { let message = PortfolioMessage::SelectDocument { document_id }; @@ -427,11 +434,71 @@ impl JsEditorHandle { self.dispatch(message); } - /// Sends the blob url generated by js - #[wasm_bindgen(js_name = setImageBlobUrl)] - pub fn set_image_blob_url(&self, path: Vec, blob_url: String, width: f64, height: f64) { - let dimensions = (width, height); - let message = Operation::SetImageBlobUrl { path, blob_url, dimensions }; + /// Sends the blob URL generated by JS to the Image layer + #[wasm_bindgen(js_name = setImageBlobURL)] + pub fn set_image_blob_url(&self, document_id: u64, layer_path: Vec, blob_url: String, width: f64, height: f64) { + let resolution = (width, height); + let message = PortfolioMessage::SetImageBlobUrl { + document_id, + layer_path, + blob_url, + resolution, + }; + self.dispatch(message); + } + + /// Sends the blob URL generated by JS to the Imaginate layer in the respective document + #[wasm_bindgen(js_name = setImaginateImageData)] + pub fn set_imaginate_image_data(&self, document_id: u64, layer_path: Vec, image_data: Vec) { + let message = PortfolioMessage::ImaginateSetImageData { document_id, layer_path, image_data }; + self.dispatch(message); + } + + /// Sends the blob URL generated by JS to the Imaginate layer in the respective document + #[wasm_bindgen(js_name = setImaginateBlobURL)] + pub fn set_imaginate_blob_url(&self, document_id: u64, layer_path: Vec, blob_url: String, width: f64, height: f64) { + let resolution = (width, height); + let message = PortfolioMessage::ImaginateSetBlobUrl { + document_id, + layer_path, + blob_url, + resolution, + }; + self.dispatch(message); + } + + /// Notifies the Imaginate layer of a new percentage of completion and whether or not it's currently generating + #[wasm_bindgen(js_name = setImaginateGeneratingStatus)] + pub fn set_imaginate_generating_status(&self, document_id: u64, path: Vec, percent: Option, status: String) { + let status = match status.as_str() { + "Idle" => ImaginateStatus::Idle, + "Beginning" => ImaginateStatus::Beginning, + "Uploading" => ImaginateStatus::Uploading(percent.expect("Percent needs to be supplied to set ImaginateStatus::Uploading")), + "Generating" => ImaginateStatus::Generating, + "Terminating" => ImaginateStatus::Terminating, + "Terminated" => ImaginateStatus::Terminated, + _ => panic!("Invalid string from JS for ImaginateStatus, received: {}", status), + }; + + let percent = if matches!(status, ImaginateStatus::Uploading(_)) { None } else { percent }; + + let message = PortfolioMessage::ImaginateSetGeneratingStatus { document_id, path, percent, status }; + self.dispatch(message); + } + + /// Notifies the editor that the Imaginate server is available or unavailable + #[wasm_bindgen(js_name = setImaginateServerStatus)] + pub fn set_imaginate_server_status(&self, available: bool) { + let message: Message = match available { + true => PortfolioMessage::ImaginateSetServerStatus { + status: ImaginateServerStatus::Connected, + } + .into(), + false => PortfolioMessage::ImaginateSetServerStatus { + status: ImaginateServerStatus::Unavailable, + } + .into(), + }; self.dispatch(message); } diff --git a/graphene/src/document.rs b/graphene/src/document.rs index cbfee30cd..63ed07d8a 100644 --- a/graphene/src/document.rs +++ b/graphene/src/document.rs @@ -2,6 +2,7 @@ use crate::boolean_ops::composite_boolean_operation; use crate::intersection::Quad; use crate::layers::folder_layer::FolderLayer; use crate::layers::image_layer::ImageLayer; +use crate::layers::imaginate_layer::{ImaginateImageData, ImaginateLayer, ImaginateStatus}; use crate::layers::layer_info::{Layer, LayerData, LayerDataType, LayerDataTypeDiscriminant}; use crate::layers::shape_layer::ShapeLayer; use crate::layers::style::RenderData; @@ -42,16 +43,44 @@ impl Default for Document { impl Document { /// Wrapper around render, that returns the whole document as a Response. pub fn render_root(&mut self, render_data: RenderData) -> String { + // Render and append to the defs section let mut svg_defs = String::from(""); - self.root.render(&mut vec![], &mut svg_defs, render_data); - svg_defs.push_str(""); + // Append the cached rendered SVG svg_defs.push_str(&self.root.cache); + svg_defs } + /// Renders everything below the given layer contained within its parent folder. + pub fn render_layers_below(&mut self, below_layer_path: &[LayerId], render_data: RenderData) -> Option { + // Split the path into the layer ID and its parent folder + let (layer_id_to_render_below, parent_folder_path) = below_layer_path.split_last()?; + + // Note: it is bad practice to directly clone and modify the Graphene document structure, this is a temporary hack until this whole system is replaced by the node graph + let mut temp_subset_folder = self.layer_mut(parent_folder_path).ok()?.clone(); + if let LayerDataType::Folder(ref mut folder) = temp_subset_folder.data { + // Remove the upper layers to leave behind the lower subset for rendering + let count_of_layers_below = folder.layer_ids.iter().position(|id| id == layer_id_to_render_below).unwrap(); + folder.layer_ids.truncate(count_of_layers_below); + folder.layers.truncate(count_of_layers_below); + + // Render and append to the defs section + let mut svg_defs = String::from(""); + temp_subset_folder.render(&mut vec![], &mut svg_defs, render_data); + svg_defs.push_str(""); + + // Append the cached rendered SVG + svg_defs.push_str(&temp_subset_folder.cache); + + Some(svg_defs) + } else { + None + } + } + pub fn current_state_identifier(&self) -> u64 { self.state_identifier.finish() } @@ -412,6 +441,7 @@ impl Document { Ok(()) } + /// For the purposes of rendering, this invalidates the render cache for the layer so it must be re-rendered next time. pub fn mark_as_dirty(&mut self, path: &[LayerId]) -> Result<(), DocumentError> { self.mark_upstream_as_dirty(path)?; Ok(()) @@ -558,6 +588,13 @@ impl Document { Some([vec![DocumentChanged, CreatedLayer { path: path.clone() }], update_thumbnails_upstream(&path)].concat()) } + Operation::AddImaginateFrame { path, insert_index, transform } => { + let layer = Layer::new(LayerDataType::Imaginate(ImaginateLayer::default()), transform); + + self.set_layer(&path, layer, insert_index)?; + + Some([vec![DocumentChanged, CreatedLayer { path: path.clone() }], update_thumbnails_upstream(&path)].concat()) + } Operation::SetTextEditability { path, editable } => { self.layer_mut(&path)?.as_text_mut()?.editable = editable; self.mark_as_dirty(&path)?; @@ -673,7 +710,7 @@ impl Document { } => { let (folder_path, layer_id) = split_path(&destination_path)?; let folder = self.folder_mut(folder_path)?; - folder.add_layer(layer, Some(layer_id), insert_index).ok_or(DocumentError::IndexOutOfBounds)?; + folder.add_layer(*layer, Some(layer_id), insert_index).ok_or(DocumentError::IndexOutOfBounds)?; self.mark_as_dirty(&destination_path)?; fn aggregate_insertions(folder: &FolderLayer, path: &mut Vec, responses: &mut Vec) { @@ -753,13 +790,180 @@ impl Document { self.mark_as_dirty(&path)?; Some([vec![DocumentChanged], update_thumbnails_upstream(&path)].concat()) } - Operation::SetImageBlobUrl { path, blob_url, dimensions } => { - let image = self.layer_mut(&path).expect("Blob url for invalid layer").as_image_mut().unwrap(); - image.blob_url = Some(blob_url); - image.dimensions = dimensions.into(); + Operation::SetLayerBlobUrl { layer_path, blob_url, resolution } => { + let layer = self.layer_mut(&layer_path).unwrap_or_else(|_| panic!("Blob url for invalid layer with path '{:?}'", layer_path)); + match &mut layer.data { + LayerDataType::Image(image) => { + image.blob_url = Some(blob_url); + image.dimensions = resolution.into(); + } + LayerDataType::Imaginate(imaginate) => { + imaginate.blob_url = Some(blob_url); + imaginate.dimensions = resolution.into(); + } + _ => panic!("Incorrectly trying to set the image blob URL for a layer that is not an Image or Imaginate layer type"), + } + + self.mark_as_dirty(&layer_path)?; + Some([vec![DocumentChanged, LayerChanged { path: layer_path.clone() }], update_thumbnails_upstream(&layer_path)].concat()) + } + Operation::ImaginateSetImageData { layer_path, image_data } => { + let layer = self.layer_mut(&layer_path).expect("Setting Imaginate image data for invalid layer"); + if let LayerDataType::Imaginate(imaginate) = &mut layer.data { + imaginate.image_data = Some(ImaginateImageData { image_data }); + } else { + panic!("Incorrectly trying to set image data for a layer that is not an Imaginate layer type"); + } + Some(vec![LayerChanged { path: layer_path.clone() }]) + } + Operation::ImaginateSetGeneratingStatus { path, percent, status } => { + let layer = self.layer_mut(&path).expect("Generating Imaginate for invalid layer"); + if let LayerDataType::Imaginate(imaginate) = &mut layer.data { + if let Some(percentage) = percent { + imaginate.percent_complete = percentage; + } + + if status == ImaginateStatus::Generating { + imaginate.image_data = None; + } + + imaginate.status = status; + } else { + panic!("Incorrectly trying to set the generating status for a layer that is not an Imaginate layer type"); + } + Some(vec![LayerChanged { path: path.clone() }]) + } + Operation::ImaginateClear { path } => { + let layer = self.layer_mut(&path).expect("Clearing Imaginate image for invalid layer"); + if let LayerDataType::Imaginate(imaginate) = &mut layer.data { + imaginate.image_data = None; + imaginate.blob_url = None; + imaginate.status = ImaginateStatus::Idle; + imaginate.percent_complete = 0.; + } else { + panic!("Incorrectly trying to clear the blob URL for a layer that is not an Imaginate layer type"); + } self.mark_as_dirty(&path)?; Some([vec![DocumentChanged, LayerChanged { path: path.clone() }], update_thumbnails_upstream(&path)].concat()) } + Operation::ImaginateSetNegativePrompt { path, negative_prompt } => { + let layer = self.layer_mut(&path).expect("Setting Imaginate negative prompt for invalid layer"); + if let LayerDataType::Imaginate(imaginate) = &mut layer.data { + imaginate.negative_prompt = negative_prompt; + } else { + panic!("Incorrectly trying to set the negative prompt for a layer that is not an Imaginate layer type"); + } + self.mark_as_dirty(&path)?; + Some(vec![LayerChanged { path }]) + } + Operation::ImaginateSetPrompt { path, prompt } => { + let layer = self.layer_mut(&path).expect("Setting Imaginate prompt for invalid layer"); + if let LayerDataType::Imaginate(imaginate) = &mut layer.data { + imaginate.prompt = prompt; + } else { + panic!("Incorrectly trying to set the prompt for a layer that is not an Imaginate layer type"); + } + self.mark_as_dirty(&path)?; + Some(vec![LayerChanged { path }]) + } + Operation::ImaginateSetCfgScale { path, cfg_scale } => { + let layer = self.layer_mut(&path).expect("Setting Imaginate CFG scale for invalid layer"); + if let LayerDataType::Imaginate(imaginate) = &mut layer.data { + imaginate.cfg_scale = cfg_scale; + } else { + panic!("Incorrectly trying to set the CFG scale for a layer that is not an Imaginate layer type"); + } + self.mark_as_dirty(&path)?; + Some(vec![LayerChanged { path }]) + } + Operation::ImaginateSetDenoisingStrength { path, denoising_strength } => { + let layer = self.layer_mut(&path).expect("Setting Imaginate denoising strength for invalid layer"); + if let LayerDataType::Imaginate(imaginate) = &mut layer.data { + imaginate.denoising_strength = denoising_strength; + } else { + panic!("Incorrectly trying to set the denoising strength for a layer that is not an Imaginate layer type"); + } + self.mark_as_dirty(&path)?; + Some(vec![LayerChanged { path }]) + } + Operation::ImaginateSetSamples { path, samples } => { + let layer = self.layer_mut(&path).expect("Setting Imaginate samples for invalid layer"); + if let LayerDataType::Imaginate(imaginate) = &mut layer.data { + imaginate.samples = samples; + } else { + panic!("Incorrectly trying to set the samples for a layer that is not an Imaginate layer type"); + } + self.mark_as_dirty(&path)?; + Some(vec![LayerChanged { path }]) + } + Operation::SetImaginateSamplingMethod { path, method } => { + let layer = self.layer_mut(&path).expect("Setting Imaginate sampling method for invalid layer"); + if let LayerDataType::Imaginate(imaginate) = &mut layer.data { + imaginate.sampling_method = method; + } else { + panic!("Incorrectly trying to set the sampling method for a layer that is not an Imaginate layer type"); + } + self.mark_as_dirty(&path)?; + Some(vec![LayerChanged { path }]) + } + Operation::ImaginateSetScaleFromResolution { path } => { + let layer = self.layer_mut(&path).expect("Setting Imaginate scale from resolution for invalid layer"); + + let (width, height) = pick_layer_safe_imaginate_resolution(layer, font_cache); + + let current_width = layer.transform.transform_vector2((1., 0.).into()).length(); + let current_height = layer.transform.transform_vector2((0., 1.).into()).length(); + + let scale_x_by = width as f64 / current_width; + let scale_y_by = height as f64 / current_height; + let scale_by_vector = DVec2::new(scale_x_by, scale_y_by); + let scale_by_matrix = DAffine2::from_scale_angle_translation(scale_by_vector, 0., (0., 0.).into()); + + layer.transform = layer.transform * scale_by_matrix; + + self.mark_as_dirty(&path)?; + Some([update_thumbnails_upstream(&path), vec![DocumentChanged, LayerChanged { path }]].concat()) + } + Operation::ImaginateSetSeed { path, seed } => { + let layer = self.layer_mut(&path).expect("Setting Imaginate seed for invalid layer"); + if let LayerDataType::Imaginate(imaginate) = &mut layer.data { + imaginate.seed = seed; + } else { + panic!("Incorrectly trying to set the seed for a layer that is not an Imaginate layer type"); + } + self.mark_as_dirty(&path)?; + Some(vec![LayerChanged { path }]) + } + Operation::ImaginateSetUseImg2Img { path, use_img2img } => { + let layer = self.layer_mut(&path).expect("Calling Imaginate img2img for invalid layer"); + if let LayerDataType::Imaginate(imaginate) = &mut layer.data { + imaginate.use_img2img = use_img2img; + } else { + panic!("Incorrectly trying to set the img2img status for a layer that is not an Imaginate layer type"); + } + self.mark_as_dirty(&path)?; + Some(vec![LayerChanged { path }]) + } + Operation::ImaginateSetRestoreFaces { path, restore_faces } => { + let layer = self.layer_mut(&path).expect("Setting Imaginate restore faces for invalid layer"); + if let LayerDataType::Imaginate(imaginate) = &mut layer.data { + imaginate.restore_faces = restore_faces; + } else { + panic!("Incorrectly trying to set the restore faces status for a layer that is not an Imaginate layer type"); + } + self.mark_as_dirty(&path)?; + Some(vec![LayerChanged { path }]) + } + Operation::ImaginateSetTiling { path, tiling } => { + let layer = self.layer_mut(&path).expect("Setting Imaginate tiling for invalid layer"); + if let LayerDataType::Imaginate(imaginate) = &mut layer.data { + imaginate.tiling = tiling; + } else { + panic!("Incorrectly trying to set the tiling status for a layer that is not an Imaginate layer type"); + } + self.mark_as_dirty(&path)?; + Some(vec![LayerChanged { path }]) + } Operation::SetPivot { layer_path, pivot } => { let layer = self.layer_mut(&layer_path).expect("Setting pivot for invalid layer"); layer.pivot = pivot.into(); @@ -1041,3 +1245,28 @@ fn update_thumbnails_upstream(path: &[LayerId]) -> Vec { } responses } + +pub fn pick_layer_safe_imaginate_resolution(layer: &Layer, font_cache: &FontCache) -> (u64, u64) { + let layer_bounds = layer.bounding_transform(font_cache); + let layer_bounds_size = (layer_bounds.transform_vector2((1., 0.).into()).length(), layer_bounds.transform_vector2((0., 1.).into()).length()); + + pick_safe_imaginate_resolution(layer_bounds_size) +} + +pub fn pick_safe_imaginate_resolution((width, height): (f64, f64)) -> (u64, u64) { + const MAX_RESOLUTION: u64 = 1000 * 1000; + + let mut scale_factor = 1.; + + let round_to_increment = |size: f64| (size / 64.).round() as u64 * 64; + + loop { + let possible_solution = (round_to_increment(width * scale_factor), round_to_increment(height * scale_factor)); + + if possible_solution.0 * possible_solution.1 <= MAX_RESOLUTION { + return possible_solution; + } + + scale_factor -= 0.1; + } +} diff --git a/graphene/src/error.rs b/graphene/src/error.rs index 67fcab609..e5f96b988 100644 --- a/graphene/src/error.rs +++ b/graphene/src/error.rs @@ -12,6 +12,7 @@ pub enum DocumentError { NotAShape, NotText, NotAnImage, + NotAnImaginate, InvalidFile(String), } diff --git a/graphene/src/layers/image_layer/base64_serde.rs b/graphene/src/layers/base64_serde.rs similarity index 100% rename from graphene/src/layers/image_layer/base64_serde.rs rename to graphene/src/layers/base64_serde.rs diff --git a/graphene/src/layers/blend_mode.rs b/graphene/src/layers/blend_mode.rs index f28cdaab0..76527f5c1 100644 --- a/graphene/src/layers/blend_mode.rs +++ b/graphene/src/layers/blend_mode.rs @@ -41,30 +41,29 @@ pub enum BlendMode { impl fmt::Display for BlendMode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let text = match self { - BlendMode::Normal => "Normal".to_string(), + match self { + BlendMode::Normal => write!(f, "Normal"), - BlendMode::Multiply => "Multiply".to_string(), - BlendMode::Darken => "Darken".to_string(), - BlendMode::ColorBurn => "Color Burn".to_string(), + BlendMode::Multiply => write!(f, "Multiply"), + BlendMode::Darken => write!(f, "Darken"), + BlendMode::ColorBurn => write!(f, "Color Burn"), - BlendMode::Screen => "Screen".to_string(), - BlendMode::Lighten => "Lighten".to_string(), - BlendMode::ColorDodge => "Color Dodge".to_string(), + BlendMode::Screen => write!(f, "Screen"), + BlendMode::Lighten => write!(f, "Lighten"), + BlendMode::ColorDodge => write!(f, "Color Dodge"), - BlendMode::Overlay => "Overlay".to_string(), - BlendMode::SoftLight => "Soft Light".to_string(), - BlendMode::HardLight => "Hard Light".to_string(), + BlendMode::Overlay => write!(f, "Overlay"), + BlendMode::SoftLight => write!(f, "Soft Light"), + BlendMode::HardLight => write!(f, "Hard Light"), - BlendMode::Difference => "Difference".to_string(), - BlendMode::Exclusion => "Exclusion".to_string(), + BlendMode::Difference => write!(f, "Difference"), + BlendMode::Exclusion => write!(f, "Exclusion"), - BlendMode::Hue => "Hue".to_string(), - BlendMode::Saturation => "Saturation".to_string(), - BlendMode::Color => "Color".to_string(), - BlendMode::Luminosity => "Luminosity".to_string(), - }; - write!(f, "{}", text) + BlendMode::Hue => write!(f, "Hue"), + BlendMode::Saturation => write!(f, "Saturation"), + BlendMode::Color => write!(f, "Color"), + BlendMode::Luminosity => write!(f, "Luminosity"), + } } } diff --git a/graphene/src/layers/folder_layer.rs b/graphene/src/layers/folder_layer.rs index 19eba77b7..d9a44619d 100644 --- a/graphene/src/layers/folder_layer.rs +++ b/graphene/src/layers/folder_layer.rs @@ -18,7 +18,7 @@ pub struct FolderLayer { /// The IDs of the [Layer]s contained within the Folder pub layer_ids: Vec, /// The [Layer]s contained in the folder - layers: Vec, + pub layers: Vec, } impl LayerData for FolderLayer { diff --git a/graphene/src/layers/image_layer.rs b/graphene/src/layers/image_layer.rs index 180ec77f2..74d9654a8 100644 --- a/graphene/src/layers/image_layer.rs +++ b/graphene/src/layers/image_layer.rs @@ -1,3 +1,4 @@ +use super::base64_serde; use super::layer_info::LayerData; use super::style::{RenderData, ViewMode}; use crate::intersection::{intersect_quad_bez_path, Quad}; @@ -9,17 +10,12 @@ use kurbo::{Affine, BezPath, Shape as KurboShape}; use serde::{Deserialize, Serialize}; use std::fmt::Write; -mod base64_serde; - -fn glam_to_kurbo(transform: DAffine2) -> Affine { - Affine::new(transform.to_cols_array()) -} - -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[derive(Clone, PartialEq, Deserialize, Serialize)] pub struct ImageLayer { pub mime: String, #[serde(serialize_with = "base64_serde::as_base64", deserialize_with = "base64_serde::from_base64")] pub image_data: Vec, + // TODO: Have the browser dispose of this blob URL when this is dropped (like when the layer is deleted) #[serde(skip)] pub blob_url: Option, #[serde(skip)] @@ -50,15 +46,12 @@ impl LayerData for ImageLayer { .collect::(); let _ = write!( svg, - r#""#, + self.dimensions.x, + self.dimensions.y, + svg_transform, + self.blob_url.as_ref().unwrap_or(&String::new()) ); - if render_data.embed_images { - let _ = write!(svg, "data:{};base64,{}", self.mime, base64::encode(&self.image_data)); - } else { - let _ = write!(svg, "{}", self.blob_url.as_ref().unwrap_or(&String::new())); - } - let _ = svg.write_str(r#""/>"#); let _ = svg.write_str(""); } @@ -83,13 +76,11 @@ impl LayerData for ImageLayer { impl ImageLayer { pub fn new(mime: String, image_data: Vec) -> Self { - let blob_url = None; - let dimensions = DVec2::ONE; Self { mime, image_data, - blob_url, - dimensions, + blob_url: None, + dimensions: DVec2::ONE, } } @@ -105,3 +96,18 @@ impl ImageLayer { kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(self.dimensions.x, self.dimensions.y)).to_path(0.) } } + +impl std::fmt::Debug for ImageLayer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ImageLayer") + .field("mime", &self.mime) + .field("image_data", &"...") + .field("blob_url", &self.blob_url) + .field("dimensions", &self.dimensions) + .finish() + } +} + +fn glam_to_kurbo(transform: DAffine2) -> Affine { + Affine::new(transform.to_cols_array()) +} diff --git a/graphene/src/layers/imaginate_layer.rs b/graphene/src/layers/imaginate_layer.rs new file mode 100644 index 000000000..463616ab0 --- /dev/null +++ b/graphene/src/layers/imaginate_layer.rs @@ -0,0 +1,286 @@ +use super::base64_serde; +use super::layer_info::LayerData; +use super::style::{RenderData, ViewMode}; +use crate::intersection::{intersect_quad_bez_path, Quad}; +use crate::layers::text_layer::FontCache; +use crate::LayerId; + +use glam::{DAffine2, DMat2, DVec2}; +use kurbo::{Affine, BezPath, Shape as KurboShape}; +use serde::{Deserialize, Serialize}; +use std::fmt::Write; + +#[derive(Clone, PartialEq, Deserialize, Serialize)] +pub struct ImaginateLayer { + // User-configurable layer parameters + pub seed: u64, + pub samples: u32, + pub sampling_method: ImaginateSamplingMethod, + pub use_img2img: bool, + pub denoising_strength: f64, + pub cfg_scale: f64, + pub prompt: String, + pub negative_prompt: String, + pub restore_faces: bool, + pub tiling: bool, + + // Image stored in layer after generation completes + pub image_data: Option, + pub mime: String, + /// 0 is not started, 100 is complete. + pub percent_complete: f64, + + // TODO: Have the browser dispose of this blob URL when this is dropped (like when the layer is deleted) + #[serde(skip)] + pub blob_url: Option, + #[serde(skip)] + pub status: ImaginateStatus, + #[serde(skip)] + pub dimensions: DVec2, +} + +#[derive(Default, Debug, Clone, PartialEq, Deserialize, Serialize)] +pub enum ImaginateStatus { + #[default] + Idle, + Beginning, + Uploading(f64), + Generating, + Terminating, + Terminated, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +pub struct ImaginateImageData { + #[serde(serialize_with = "base64_serde::as_base64", deserialize_with = "base64_serde::from_base64")] + pub image_data: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ImaginateBaseImage { + pub svg: String, + pub size: DVec2, +} + +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Deserialize, Serialize)] +pub enum ImaginateSamplingMethod { + #[default] + EulerA, + Euler, + LMS, + Heun, + DPM2, + DPM2A, + DPMFast, + DPMAdaptive, + LMSKarras, + DPM2Karras, + DPM2AKarras, + DDIM, + PLMS, +} + +impl ImaginateSamplingMethod { + pub fn api_value(&self) -> &str { + match self { + ImaginateSamplingMethod::EulerA => "Euler a", + ImaginateSamplingMethod::Euler => "Euler", + ImaginateSamplingMethod::LMS => "LMS", + ImaginateSamplingMethod::Heun => "Heun", + ImaginateSamplingMethod::DPM2 => "DPM2", + ImaginateSamplingMethod::DPM2A => "DPM2 a", + ImaginateSamplingMethod::DPMFast => "DPM fast", + ImaginateSamplingMethod::DPMAdaptive => "DPM adaptive", + ImaginateSamplingMethod::LMSKarras => "LMS Karras", + ImaginateSamplingMethod::DPM2Karras => "DPM2 Karras", + ImaginateSamplingMethod::DPM2AKarras => "DPM2 a Karras", + ImaginateSamplingMethod::DDIM => "DDIM", + ImaginateSamplingMethod::PLMS => "PLMS", + } + } + + pub fn list() -> [ImaginateSamplingMethod; 13] { + [ + ImaginateSamplingMethod::EulerA, + ImaginateSamplingMethod::Euler, + ImaginateSamplingMethod::LMS, + ImaginateSamplingMethod::Heun, + ImaginateSamplingMethod::DPM2, + ImaginateSamplingMethod::DPM2A, + ImaginateSamplingMethod::DPMFast, + ImaginateSamplingMethod::DPMAdaptive, + ImaginateSamplingMethod::LMSKarras, + ImaginateSamplingMethod::DPM2Karras, + ImaginateSamplingMethod::DPM2AKarras, + ImaginateSamplingMethod::DDIM, + ImaginateSamplingMethod::PLMS, + ] + } +} + +impl std::fmt::Display for ImaginateSamplingMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ImaginateSamplingMethod::EulerA => write!(f, "Euler A (Recommended)"), + ImaginateSamplingMethod::Euler => write!(f, "Euler"), + ImaginateSamplingMethod::LMS => write!(f, "LMS"), + ImaginateSamplingMethod::Heun => write!(f, "Heun"), + ImaginateSamplingMethod::DPM2 => write!(f, "DPM2"), + ImaginateSamplingMethod::DPM2A => write!(f, "DPM2 A"), + ImaginateSamplingMethod::DPMFast => write!(f, "DPM Fast"), + ImaginateSamplingMethod::DPMAdaptive => write!(f, "DPM Adaptive"), + ImaginateSamplingMethod::LMSKarras => write!(f, "LMS Karras"), + ImaginateSamplingMethod::DPM2Karras => write!(f, "DPM2 Karras"), + ImaginateSamplingMethod::DPM2AKarras => write!(f, "DPM2 A Karras"), + ImaginateSamplingMethod::DDIM => write!(f, "DDIM"), + ImaginateSamplingMethod::PLMS => write!(f, "PLMS"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ImaginateGenerationParameters { + pub seed: u64, + pub samples: u32, + /// Use `ImaginateSamplingMethod::api_value()` to generate this string + #[serde(rename = "samplingMethod")] + pub sampling_method: String, + #[serde(rename = "denoisingStrength")] + pub denoising_strength: Option, + #[serde(rename = "cfgScale")] + pub cfg_scale: f64, + pub prompt: String, + #[serde(rename = "negativePrompt")] + pub negative_prompt: String, + pub resolution: (u64, u64), + #[serde(rename = "restoreFaces")] + pub restore_faces: bool, + pub tiling: bool, +} + +impl Default for ImaginateLayer { + fn default() -> Self { + Self { + seed: 0, + samples: 30, + sampling_method: Default::default(), + use_img2img: false, + denoising_strength: 0.66, + cfg_scale: 10., + prompt: "".into(), + negative_prompt: "".into(), + restore_faces: false, + tiling: false, + + image_data: None, + mime: "image/png".into(), + + blob_url: None, + percent_complete: 0., + status: Default::default(), + dimensions: Default::default(), + } + } +} + +impl LayerData for ImaginateLayer { + fn render(&mut self, svg: &mut String, _svg_defs: &mut String, transforms: &mut Vec, render_data: RenderData) { + let transform = self.transform(transforms, render_data.view_mode); + let inverse = transform.inverse(); + + let matrix_values = transform.matrix2.to_cols_array(); + let (width, height) = (matrix_values[0], matrix_values[3]); + + if !inverse.is_finite() { + let _ = write!(svg, ""); + return; + } + + let _ = writeln!(svg, r#""#); + + if let Some(blob_url) = &self.blob_url { + let _ = write!( + svg, + r#""#, + width.abs(), + height.abs(), + if width >= 0. { transform.translation.x } else { transform.translation.x + width }, + if height >= 0. { transform.translation.y } else { transform.translation.y + height }, + blob_url + ); + } else { + let _ = write!( + svg, + r#""#, + width.abs(), + height.abs(), + if width >= 0. { transform.translation.x } else { transform.translation.x + width }, + if height >= 0. { transform.translation.y } else { transform.translation.y + height }, + ); + } + + let _ = svg.write_str(""); + } + + fn bounding_box(&self, transform: glam::DAffine2, _font_cache: &FontCache) -> Option<[DVec2; 2]> { + let mut path = self.bounds(); + + if transform.matrix2 == DMat2::ZERO { + return None; + } + path.apply_affine(glam_to_kurbo(transform)); + + let kurbo::Rect { x0, y0, x1, y1 } = path.bounding_box(); + Some([(x0, y0).into(), (x1, y1).into()]) + } + + fn intersects_quad(&self, quad: Quad, path: &mut Vec, intersections: &mut Vec>, _font_cache: &FontCache) { + if intersect_quad_bez_path(quad, &self.bounds(), true) { + intersections.push(path.clone()); + } + } +} + +impl ImaginateLayer { + pub fn transform(&self, transforms: &[DAffine2], mode: ViewMode) -> DAffine2 { + let start = match mode { + ViewMode::Outline => 0, + _ => (transforms.len() as i32 - 1).max(0) as usize, + }; + transforms.iter().skip(start).cloned().reduce(|a, b| a * b).unwrap_or(DAffine2::IDENTITY) + } + + fn bounds(&self) -> BezPath { + kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.)).to_path(0.) + } +} + +fn glam_to_kurbo(transform: DAffine2) -> Affine { + Affine::new(transform.to_cols_array()) +} + +impl std::fmt::Debug for ImaginateLayer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ImaginateLayer") + .field("seed", &self.seed) + .field("samples", &self.samples) + .field("use_img2img", &self.use_img2img) + .field("denoising_strength", &self.denoising_strength) + .field("cfg_scale", &self.cfg_scale) + .field("prompt", &self.prompt) + .field("negative_prompt", &self.negative_prompt) + .field("restore_faces", &self.restore_faces) + .field("tiling", &self.tiling) + .field("image_data", &self.image_data.as_ref().map(|_| "...")) + .field("mime", &self.mime) + .field("percent_complete", &self.percent_complete) + .field("blob_url", &self.blob_url) + .field("status", &self.status) + .field("dimensions", &self.dimensions) + .finish() + } +} diff --git a/graphene/src/layers/layer_info.rs b/graphene/src/layers/layer_info.rs index 01f8a5509..bf46bf798 100644 --- a/graphene/src/layers/layer_info.rs +++ b/graphene/src/layers/layer_info.rs @@ -1,6 +1,7 @@ use super::blend_mode::BlendMode; use super::folder_layer::FolderLayer; use super::image_layer::ImageLayer; +use super::imaginate_layer::ImaginateLayer; use super::shape_layer::ShapeLayer; use super::style::{PathStyle, RenderData}; use super::text_layer::TextLayer; @@ -26,6 +27,8 @@ pub enum LayerDataType { Text(TextLayer), /// A layer that wraps an [ImageLayer] struct. Image(ImageLayer), + /// A layer that wraps an [ImageLayer] struct. + Imaginate(ImaginateLayer), } impl LayerDataType { @@ -35,6 +38,7 @@ impl LayerDataType { LayerDataType::Folder(f) => f, LayerDataType::Text(t) => t, LayerDataType::Image(i) => i, + LayerDataType::Imaginate(a) => a, } } @@ -44,6 +48,7 @@ impl LayerDataType { LayerDataType::Folder(f) => f, LayerDataType::Text(t) => t, LayerDataType::Image(i) => i, + LayerDataType::Imaginate(a) => a, } } } @@ -54,18 +59,18 @@ pub enum LayerDataTypeDiscriminant { Shape, Text, Image, + Imaginate, } impl fmt::Display for LayerDataTypeDiscriminant { - fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - let name = match self { - LayerDataTypeDiscriminant::Folder => "Folder", - LayerDataTypeDiscriminant::Shape => "Shape", - LayerDataTypeDiscriminant::Text => "Text", - LayerDataTypeDiscriminant::Image => "Image", - }; - - formatter.write_str(name) + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + LayerDataTypeDiscriminant::Folder => write!(f, "Folder"), + LayerDataTypeDiscriminant::Shape => write!(f, "Shape"), + LayerDataTypeDiscriminant::Text => write!(f, "Text"), + LayerDataTypeDiscriminant::Image => write!(f, "Image"), + LayerDataTypeDiscriminant::Imaginate => write!(f, "Imaginate"), + } } } @@ -78,6 +83,7 @@ impl From<&LayerDataType> for LayerDataTypeDiscriminant { Shape(_) => LayerDataTypeDiscriminant::Shape, Text(_) => LayerDataTypeDiscriminant::Text, Image(_) => LayerDataTypeDiscriminant::Image, + Imaginate(_) => LayerDataTypeDiscriminant::Imaginate, } } } @@ -98,7 +104,7 @@ pub trait LayerData { /// /// // Render the shape without any transforms, in normal view mode /// # let font_cache = Default::default(); - /// let render_data = RenderData::new(ViewMode::Normal, &font_cache, None, false); + /// let render_data = RenderData::new(ViewMode::Normal, &font_cache, None); /// shape.render(&mut svg, &mut String::new(), &mut vec![], render_data); /// /// assert_eq!( @@ -361,7 +367,7 @@ impl Layer { let dimensions = b - a; DAffine2::from_scale(dimensions) } - _ => DAffine2::IDENTITY, + None => DAffine2::IDENTITY, }; self.transform * scale @@ -447,6 +453,24 @@ impl Layer { } } + /// Get a mutable reference to the Imaginate element wrapped by the layer. + /// This operation will fail if the [Layer type](Layer::data) is not `LayerDataType::Imaginate`. + pub fn as_imaginate_mut(&mut self) -> Result<&mut ImaginateLayer, DocumentError> { + match &mut self.data { + LayerDataType::Imaginate(imaginate) => Ok(imaginate), + _ => Err(DocumentError::NotAnImaginate), + } + } + + /// Get a reference to the Imaginate element wrapped by the layer. + /// This operation will fail if the [Layer type](Layer::data) is not `LayerDataType::Imaginate`. + pub fn as_imaginate(&self) -> Result<&ImaginateLayer, DocumentError> { + match &self.data { + LayerDataType::Imaginate(imaginate) => Ok(imaginate), + _ => Err(DocumentError::NotAnImaginate), + } + } + pub fn style(&self) -> Result<&PathStyle, DocumentError> { match &self.data { LayerDataType::Shape(s) => Ok(&s.style), diff --git a/graphene/src/layers/mod.rs b/graphene/src/layers/mod.rs index 7cd3efc8c..1f15e904d 100644 --- a/graphene/src/layers/mod.rs +++ b/graphene/src/layers/mod.rs @@ -6,6 +6,7 @@ //! * [Shape layers](shape_layer::ShapeLayer), which contain generic SVG [``](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/path)s //! * [Text layers](text_layer::TextLayer), which contain a description of laid out text //! * [Image layers](image_layer::ImageLayer), which contain a bitmap image +//! * [Imaginate layers](imaginate_layer::ImaginateLayer), which contain a bitmap image generated based on a prompt and optionally the layers beneath it //! //! Refer to the module-level documentation for detailed information on each layer. //! @@ -14,6 +15,7 @@ //! When different layers overlap, they are blended together according to the [BlendMode](blend_mode::BlendMode) //! using the CSS [`mix-blend-mode`](https://developer.mozilla.org/en-US/docs/Web/CSS/mix-blend-mode) property and the layer opacity. +pub mod base64_serde; /// Different ways of combining overlapping SVG elements. pub mod blend_mode; /// Contains the [FolderLayer](folder_layer::FolderLayer) type that encapsulates other layers, including more folders. @@ -21,6 +23,8 @@ pub mod folder_layer; pub mod id_vec; /// Contains the [ImageLayer](image_layer::ImageLayer) type that contains a bitmap image. pub mod image_layer; +/// Contains the [ImaginateLayer](imaginate_layer::ImaginateLayer) type that contains a bitmap image generated based on a prompt and optionally the layers beneath it. +pub mod imaginate_layer; /// Contains the base [Layer](layer_info::Layer) type, an abstraction over the different types of layers. pub mod layer_info; /// Contains the [ShapeLayer](shape_layer::ShapeLayer) type, a generic SVG element defined using Bezier paths. diff --git a/graphene/src/layers/style/mod.rs b/graphene/src/layers/style/mod.rs index e6052dc97..f12fadb69 100644 --- a/graphene/src/layers/style/mod.rs +++ b/graphene/src/layers/style/mod.rs @@ -43,16 +43,14 @@ pub struct RenderData<'a> { pub view_mode: ViewMode, pub font_cache: &'a FontCache, pub culling_bounds: Option<[DVec2; 2]>, - pub embed_images: bool, } impl<'a> RenderData<'a> { - pub fn new(view_mode: ViewMode, font_cache: &'a FontCache, culling_bounds: Option<[DVec2; 2]>, embed_images: bool) -> Self { + pub fn new(view_mode: ViewMode, font_cache: &'a FontCache, culling_bounds: Option<[DVec2; 2]>) -> Self { Self { view_mode, font_cache, culling_bounds, - embed_images, } } } @@ -204,11 +202,11 @@ pub enum LineCap { impl Display for LineCap { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match &self { - LineCap::Butt => "butt", - LineCap::Round => "round", - LineCap::Square => "square", - }) + match self { + LineCap::Butt => write!(f, "butt"), + LineCap::Round => write!(f, "round"), + LineCap::Square => write!(f, "square"), + } } } @@ -222,11 +220,11 @@ pub enum LineJoin { impl Display for LineJoin { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match &self { - LineJoin::Bevel => "bevel", - LineJoin::Miter => "miter", - LineJoin::Round => "round", - }) + match self { + LineJoin::Bevel => write!(f, "bevel"), + LineJoin::Miter => write!(f, "miter"), + LineJoin::Round => write!(f, "round"), + } } } diff --git a/graphene/src/operation.rs b/graphene/src/operation.rs index 78d4d3591..331d086b2 100644 --- a/graphene/src/operation.rs +++ b/graphene/src/operation.rs @@ -1,5 +1,6 @@ use crate::boolean_ops::BooleanOperation as BooleanOperationType; use crate::layers::blend_mode::BlendMode; +use crate::layers::imaginate_layer::{ImaginateSamplingMethod, ImaginateStatus}; use crate::layers::layer_info::Layer; use crate::layers::style::{self, Stroke}; use crate::layers::vector::consts::ManipulatorType; @@ -36,25 +37,89 @@ pub enum Operation { }, AddText { path: Vec, - transform: [f64; 6], insert_index: isize, - text: String, + transform: [f64; 6], style: style::PathStyle, + text: String, size: f64, font_name: String, font_style: String, }, AddImage { path: Vec, - transform: [f64; 6], insert_index: isize, + transform: [f64; 6], mime: String, image_data: Vec, }, - SetImageBlobUrl { + AddImaginateFrame { path: Vec, + insert_index: isize, + transform: [f64; 6], + }, + /// Sets a blob URL as the image source for an Image or Imaginate layer type. + /// **Be sure to call `FrontendMessage::TriggerRevokeBlobUrl` together with this.** + SetLayerBlobUrl { + layer_path: Vec, blob_url: String, - dimensions: (f64, f64), + resolution: (f64, f64), + }, + /// Clears the image to leave the Imaginate layer un-rendered. + /// **Be sure to call `FrontendMessage::TriggerRevokeBlobUrl` together with this.** + ImaginateClear { + path: Vec, + }, + ImaginateSetGeneratingStatus { + path: Vec, + percent: Option, + status: ImaginateStatus, + }, + ImaginateSetImageData { + layer_path: Vec, + image_data: Vec, + }, + ImaginateSetNegativePrompt { + path: Vec, + negative_prompt: String, + }, + ImaginateSetPrompt { + path: Vec, + prompt: String, + }, + ImaginateSetCfgScale { + path: Vec, + cfg_scale: f64, + }, + ImaginateSetSamples { + path: Vec, + samples: u32, + }, + SetImaginateSamplingMethod { + path: Vec, + method: ImaginateSamplingMethod, + }, + ImaginateSetScaleFromResolution { + path: Vec, + }, + ImaginateSetSeed { + path: Vec, + seed: u64, + }, + ImaginateSetDenoisingStrength { + path: Vec, + denoising_strength: f64, + }, + ImaginateSetUseImg2Img { + path: Vec, + use_img2img: bool, + }, + ImaginateSetRestoreFaces { + path: Vec, + restore_faces: bool, + }, + ImaginateSetTiling { + path: Vec, + tiling: bool, }, SetPivot { layer_path: Vec, @@ -70,32 +135,32 @@ pub enum Operation { }, AddPolyline { path: Vec, - transform: [f64; 6], insert_index: isize, - points: Vec<(f64, f64)>, + transform: [f64; 6], style: style::PathStyle, + points: Vec<(f64, f64)>, }, AddSpline { path: Vec, - transform: [f64; 6], insert_index: isize, - points: Vec<(f64, f64)>, + transform: [f64; 6], style: style::PathStyle, + points: Vec<(f64, f64)>, }, AddNgon { path: Vec, insert_index: isize, transform: [f64; 6], - sides: u32, style: style::PathStyle, + sides: u32, }, AddShape { path: Vec, - transform: [f64; 6], insert_index: isize, + transform: [f64; 6], + style: style::PathStyle, // TODO This will become a compound path once we support them. subpath: Subpath, - style: style::PathStyle, }, BooleanOperation { operation: BooleanOperationType, @@ -120,8 +185,8 @@ pub enum Operation { ModifyFont { path: Vec, font_family: String, - font_style: String, size: f64, + font_style: String, }, MoveSelectedManipulatorPoints { layer_path: Vec, @@ -144,7 +209,7 @@ pub enum Operation { new_name: String, }, InsertLayer { - layer: Layer, + layer: Box, destination_path: Vec, insert_index: isize, }, diff --git a/graphene/src/response.rs b/graphene/src/response.rs index 849aa6a82..979ddddf6 100644 --- a/graphene/src/response.rs +++ b/graphene/src/response.rs @@ -6,23 +6,31 @@ use std::fmt; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] #[repr(C)] pub enum DocumentResponse { + /// For the purposes of rendering, this triggers a re-render of the entire document. DocumentChanged, - FolderChanged { path: Vec }, - CreatedLayer { path: Vec }, - DeletedLayer { path: Vec }, - LayerChanged { path: Vec }, + FolderChanged { + path: Vec, + }, + CreatedLayer { + path: Vec, + }, + DeletedLayer { + path: Vec, + }, + /// Triggers an update of the layer in the layer panel. + LayerChanged { + path: Vec, + }, } impl fmt::Display for DocumentResponse { - fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - let name = match self { - DocumentResponse::DocumentChanged { .. } => "DocumentChanged", - DocumentResponse::FolderChanged { .. } => "FolderChanged", - DocumentResponse::CreatedLayer { .. } => "CreatedLayer", - DocumentResponse::LayerChanged { .. } => "LayerChanged", - DocumentResponse::DeletedLayer { .. } => "DeleteLayer", - }; - - formatter.write_str(name) + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + DocumentResponse::DocumentChanged { .. } => write!(f, "DocumentChanged"), + DocumentResponse::FolderChanged { .. } => write!(f, "FolderChanged"), + DocumentResponse::CreatedLayer { .. } => write!(f, "CreatedLayer"), + DocumentResponse::LayerChanged { .. } => write!(f, "LayerChanged"), + DocumentResponse::DeletedLayer { .. } => write!(f, "DeleteLayer"), + } } }