Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ impl App {
let wake = Arc::new(move || {
wake_scheduler.schedule(AppEvent::DesktopWrapperMessage(DesktopWrapperMessage::Wake));
});
let desktop_wrapper = DesktopWrapper::new(rand::rng().random(), Arc::new(resource_storage), wgpu_context.clone(), wake);
let desktop_wrapper = DesktopWrapper::new(rand::rng().random(), Arc::new(resource_storage), dirs::app_autosave_documents_dir(), wgpu_context.clone(), wake);

Self {
render_state: None,
Expand Down
7 changes: 5 additions & 2 deletions desktop/wrapper/src/intercept_frontend_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,16 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
if let Some(path) = path {
dispatcher.respond(DesktopFrontendMessage::WriteFile { path, content });
} else {
// Derive the dialog filter from the suggested filename's extension so it tracks the
// editor's save-format preference (.gdd container or plain .graphite).
let extension = std::path::Path::new(&name).extension().and_then(|extension| extension.to_str()).unwrap_or("graphite").to_string();
dispatcher.respond(DesktopFrontendMessage::SaveFileDialog {
title: "Save Document".to_string(),
default_filename: name,
default_folder: folder,
filters: vec![FileFilter {
name: "Graphite".to_string(),
extensions: vec!["graphite".to_string()],
name: "Graphite Document".to_string(),
extensions: vec![extension],
}],
context: SaveFileDialogContext::Document { document_id, content },
});
Expand Down
4 changes: 2 additions & 2 deletions desktop/wrapper/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ pub struct DesktopWrapper {
}

impl DesktopWrapper {
pub fn new(uuid_random_seed: u64, resource_storage: Arc<dyn ResourceStorage>, wgpu_context: WgpuContext, schedule_wake: Wake) -> Self {
pub fn new(uuid_random_seed: u64, resource_storage: Arc<dyn ResourceStorage>, working_copy_root: std::path::PathBuf, wgpu_context: WgpuContext, schedule_wake: Wake) -> Self {
#[cfg(target_os = "windows")]
let host = Host::Windows;
#[cfg(target_os = "macos")]
Expand All @@ -37,7 +37,7 @@ impl DesktopWrapper {
let application_io = PlatformApplicationIo::new_with_context(wgpu_context);

Self {
editor: Editor::new(env, uuid_random_seed, resource_storage, application_io, schedule_wake),
editor: Editor::new(env, uuid_random_seed, resource_storage, Some(working_copy_root), application_io, schedule_wake),
}
}

Expand Down
3 changes: 3 additions & 0 deletions editor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ gpu = ["interpreted-executor/gpu", "dep:wgpu-executor"]
# Local dependencies
graphite-proc-macros = { workspace = true }
graph-craft = { workspace = true }
graph-storage = { workspace = true, features = ["conversion"] }
document-format = { workspace = true }
document-container = { workspace = true }
graphene-hash = { workspace = true }
interpreted-executor = { workspace = true }
graphene-std = { workspace = true } # NOTE: `core-types` should not be added here because `graphene-std` re-exports its contents
Expand Down
11 changes: 9 additions & 2 deletions editor/src/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,18 @@ pub struct Editor {
}

impl Editor {
pub fn new(environment: Environment, uuid_random_seed: u64, resource_storage: Arc<dyn ResourceStorage>, mut application_io: PlatformApplicationIo, wake: Wake) -> Self {
pub fn new(
environment: Environment,
uuid_random_seed: u64,
resource_storage: Arc<dyn ResourceStorage>,
working_copy_root: Option<std::path::PathBuf>,
mut application_io: PlatformApplicationIo,
wake: Wake,
) -> Self {
ENVIRONMENT.set(environment).expect("Editor shoud only be initialized once");
graphene_std::uuid::set_uuid_seed(uuid_random_seed);

let mut dispatcher = Dispatcher::new(resource_storage);
let mut dispatcher = Dispatcher::new(resource_storage, working_copy_root);
dispatcher.message_handlers.future_message_handler.set_wake(wake);
application_io.inject_resource_proxy(dispatcher.message_handlers.resource_storage_message_handler.resources());
crate::node_graph_executor::replace_application_io(application_io);
Expand Down
3 changes: 3 additions & 0 deletions editor/src/consts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ pub const COLOR_OVERLAY_BLACK_75: &str = "#000000bf";

// DOCUMENT
pub const FILE_EXTENSION: &str = "graphite";
/// New document container format. Save writes a `.gdd` with the legacy `.graphite` embedded as a fallback
/// during the dual-write soak; `.graphite` stays a supported open/import input.
pub const GDD_FILE_EXTENSION: &str = "gdd";
Comment thread
TrueDoctor marked this conversation as resolved.
pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document";
pub const MAX_UNDO_HISTORY_LEN: usize = 100; // TODO: Add this to user preferences
pub const AUTO_SAVE_TIMEOUT_SECONDS: u64 = 1;
Expand Down
13 changes: 12 additions & 1 deletion editor/src/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,10 @@ const DEBUG_MESSAGE_BLOCK_LIST: &[MessageDiscriminant] = &[
const DEBUG_MESSAGE_ENDING_BLOCK_LIST: &[&str] = &["PointerMove", "PointerOutsideViewport", "Overlays", "Draw", "CurrentTime", "Time"];

impl Dispatcher {
pub fn new(resource_storage: Arc<dyn ResourceStorage>) -> Self {
pub fn new(resource_storage: Arc<dyn ResourceStorage>, working_copy_root: Option<std::path::PathBuf>) -> Self {
let mut s = Self::default();
s.message_handlers.resource_storage_message_handler = ResourceStorageMessageHandler::new(resource_storage);
s.message_handlers.portfolio_message_handler.set_working_copy_root(working_copy_root);
s
}

Expand Down Expand Up @@ -264,6 +265,7 @@ impl Dispatcher {
menu_bar_message_handler.properties_panel_open = layout.is_panel_present(PanelType::Properties);
menu_bar_message_handler.message_logging_verbosity = self.message_handlers.debug_message_handler.message_logging_verbosity;
menu_bar_message_handler.reset_node_definitions_on_open = self.message_handlers.portfolio_message_handler.reset_node_definitions_on_open;
menu_bar_message_handler.show_storage_preferences = self.message_handlers.preferences_message_handler.show_storage_preferences;

if let Some(document) = self
.message_handlers
Expand Down Expand Up @@ -367,6 +369,15 @@ impl Dispatcher {
self.message_handlers.portfolio_message_handler.poll_node_graph_evaluation(responses)
}

/// Block until no async work is in flight. Each result feeds back through `handle_message`, which may
/// spawn more, so loop until the in-flight count drains. Test-only: production pumps results lazily.
pub async fn settle_async_work(&mut self) {
while self.message_handlers.future_message_handler.has_in_flight() {
let Some(message) = self.message_handlers.future_message_handler.recv_next().await else { break };
self.handle_message(message, true);
}
}

/// Create the tree structure for logging the messages as a tree
fn create_indents(queues: &[VecDeque<Message>]) -> String {
String::from_iter(queues.iter().enumerate().skip(1).map(|(index, queue)| {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,62 @@ impl PreferencesDialogMessageHandler {
rows.extend_from_slice(&[header, node_graph_wires_label, graph_wire_style, brush_tool]);
}

// =========
// DOCUMENTS
// =========
// Soak-only `.gdd` storage options, hidden unless enabled from the developer debug menu.
if preferences.show_storage_preferences {
let header = vec![TextLabel::new("Documents").italic(true).widget_instance()];

let save_as_gdd_description = "
Save documents in the new .gdd container format (with the legacy .graphite file embedded as a recovery fallback) instead of a plain .graphite file. The .gdd format is still being validated, so this is opt-in for now.\n\
\n\
*Default: Off.*
"
.trim();
let save_as_gdd_checkbox_id = CheckboxId::new();
let save_as_gdd = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
CheckboxInput::new(preferences.save_as_gdd)
.tooltip_label("Save as .gdd")
.tooltip_description(save_as_gdd_description)
.on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::SaveAsGdd { enabled: checkbox_input.checked }.into())
.for_label(save_as_gdd_checkbox_id)
.widget_instance(),
TextLabel::new("Save as .gdd")
.tooltip_label("Save as .gdd")
.tooltip_description(save_as_gdd_description)
.for_checkbox(save_as_gdd_checkbox_id)
.widget_instance(),
];

let validate_description = "
Validate every document save, open, and undo by round-tripping it through the .gdd storage format and comparing against the legacy path, logging any mismatch. Useful for debugging the .gdd format during its soak, but the per-edit round-trip has a performance cost.\n\
\n\
*Default: Off.*
"
.trim();
let validate_checkbox_id = CheckboxId::new();
let validate_storage_round_trip = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
CheckboxInput::new(preferences.validate_storage_round_trip)
.tooltip_label("Validate Storage Round-Trip")
.tooltip_description(validate_description)
.on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::ValidateStorageRoundTrip { enabled: checkbox_input.checked }.into())
.for_label(validate_checkbox_id)
.widget_instance(),
TextLabel::new("Validate Storage Round-Trip")
.tooltip_label("Validate Storage Round-Trip")
.tooltip_description(validate_description)
.for_checkbox(validate_checkbox_id)
.widget_instance(),
];

rows.extend_from_slice(&[header, save_as_gdd, validate_storage_round_trip]);
}

// =============
// COMPATIBILITY
// =============
Expand Down
69 changes: 43 additions & 26 deletions editor/src/messages/future/future_message_handler.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
use std::future::{Future, IntoFuture};
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};

use dyn_any::WasmNotSend;
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};

use crate::messages::prelude::*;

// Native spawns onto a multi-thread tokio runtime, so the boxed future must be `Send`. Wasm uses
// `spawn_local` on the single JS thread, where `Send` is unavailable (OPFS/`JsFuture` are `!Send`) and
// unnecessary. `WasmNotSend` (`Send` on native, no-op on wasm) expresses the `MessageFuture::new` input
// bound; the stored `dyn` alias still needs a `cfg` split, since `Send` works in a `dyn` bound but the
// `WasmNotSend` alias does not.
#[cfg(not(target_family = "wasm"))]
type InnerMessageFuture = Pin<Box<dyn Future<Output = Message> + Send + 'static>>;
#[cfg(target_family = "wasm")]
Expand Down Expand Up @@ -77,6 +83,9 @@ pub struct FutureMessageHandler {
wake: Wake,
results_sender: UnboundedSender<Message>,
results_receiver: UnboundedReceiver<Message>,
/// Spawned futures whose result has not yet been drained. Incremented at spawn, decremented on drain,
/// so `0` plus an empty channel means no async work is in flight. Lets the test harness settle first.
in_flight: Arc<AtomicUsize>,
}

impl FutureMessageHandler {
Expand All @@ -87,19 +96,37 @@ impl FutureMessageHandler {
wake,
results_sender,
results_receiver,
in_flight: Arc::new(AtomicUsize::new(0)),
}
}

pub fn set_wake(&mut self, wake: Wake) {
self.wake = wake;
}

/// Pull every resolved async result into `out`.
/// Pull every resolved async result into `out`, decrementing the in-flight count per result.
pub fn drain_results(&mut self, out: &mut VecDeque<Message>) {
while let Ok(Some(message)) = self.results_receiver.try_next() {
self.in_flight.fetch_sub(1, Ordering::AcqRel);
out.push_back(message);
}
}

/// Whether any spawned future has not yet had its result drained.
pub fn has_in_flight(&self) -> bool {
self.in_flight.load(Ordering::Acquire) != 0
}

/// Await the next async result, decrementing the in-flight count. `None` only if the channel closed
/// (sender dropped), which can't happen while the handler is alive.
pub async fn recv_next(&mut self) -> Option<Message> {
use futures::StreamExt;
let message = self.results_receiver.next().await;
if message.is_some() {
self.in_flight.fetch_sub(1, Ordering::AcqRel);
}
message
}
}

impl Default for FutureMessageHandler {
Expand All @@ -119,6 +146,7 @@ impl MessageHandler<FutureMessage, FutureMessageContext> for FutureMessageHandle
fn process_message(&mut self, message: FutureMessage, _responses: &mut VecDeque<Message>, _context: FutureMessageContext) {
match message {
FutureMessage::Await { future } => {
self.in_flight.fetch_add(1, Ordering::AcqRel);
self.spawner.spawn(future.into_future(), self.results_sender.clone(), self.wake.clone());
}
FutureMessage::Wake => {
Expand All @@ -132,45 +160,34 @@ impl MessageHandler<FutureMessage, FutureMessageContext> for FutureMessageHandle

#[cfg(not(target_family = "wasm"))]
fn default_spawner() -> Arc<dyn MessageSpawner> {
Arc::new(TokioSpawner::default())
Arc::new(TokioSpawner)
}

#[cfg(target_family = "wasm")]
fn default_spawner() -> Arc<dyn MessageSpawner> {
Arc::new(WasmSpawner)
}

/// Process-global runtime for editor async work, leaked via `LazyLock` so it is never dropped: dropping a
/// `tokio::runtime::Runtime` blocks to join its worker threads, which panics inside an async context (e.g.
/// a `#[tokio::test]` body or the desktop event loop).
#[cfg(not(target_family = "wasm"))]
struct TokioSpawner {
/// Built lazily on first spawn. `multi_thread(1)` lets Tokio manage its own driver.
runtime: std::sync::OnceLock<tokio::runtime::Runtime>,
}
static EDITOR_ASYNC_RUNTIME: std::sync::LazyLock<tokio::runtime::Runtime> = std::sync::LazyLock::new(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.thread_name("graphite-async")
.enable_all()
.build()
.expect("failed to construct async-message tokio runtime")
});

#[cfg(not(target_family = "wasm"))]
impl Default for TokioSpawner {
fn default() -> Self {
Self { runtime: std::sync::OnceLock::new() }
}
}

#[cfg(not(target_family = "wasm"))]
impl TokioSpawner {
fn runtime(&self) -> &tokio::runtime::Runtime {
self.runtime.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.thread_name("graphite-async")
.enable_all()
.build()
.expect("failed to construct async-message tokio runtime")
})
}
}
struct TokioSpawner;

#[cfg(not(target_family = "wasm"))]
impl MessageSpawner for TokioSpawner {
fn spawn(&self, future: InnerMessageFuture, results: UnboundedSender<Message>, wake: Wake) {
self.runtime().spawn(async move {
EDITOR_ASYNC_RUNTIME.spawn(async move {
let message = future.await;
let _ = results.unbounded_send(message);
wake();
Expand Down
6 changes: 6 additions & 0 deletions editor/src/messages/menu_bar/menu_bar_message_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub struct MenuBarMessageHandler {
pub has_selection_history: (bool, bool),
pub message_logging_verbosity: MessageLoggingVerbosity,
pub reset_node_definitions_on_open: bool,
pub show_storage_preferences: bool,
pub make_path_editable_is_allowed: bool,
pub data_panel_open: bool,
pub layers_panel_open: bool,
Expand Down Expand Up @@ -50,6 +51,7 @@ impl LayoutHolder for MenuBarMessageHandler {
let message_logging_verbosity_names = self.message_logging_verbosity == MessageLoggingVerbosity::Names;
let message_logging_verbosity_contents = self.message_logging_verbosity == MessageLoggingVerbosity::Contents;
let reset_node_definitions_on_open = self.reset_node_definitions_on_open;
let show_storage_preferences = self.show_storage_preferences;
let make_path_editable_is_allowed = self.make_path_editable_is_allowed;

let about = MenuListEntry::new("About Graphite…")
Expand Down Expand Up @@ -718,6 +720,10 @@ impl LayoutHolder for MenuBarMessageHandler {
.label("Reset Nodes to Definitions on Open")
.icon(if reset_node_definitions_on_open { "CheckboxChecked" } else { "CheckboxUnchecked" })
.on_commit(|_| PortfolioMessage::ToggleResetNodesToDefinitionsOnOpen.into()),
MenuListEntry::new("Show Storage Preferences")
.label("Show Storage Preferences")
.icon(if show_storage_preferences { "CheckboxChecked" } else { "CheckboxUnchecked" })
.on_commit(|_| PreferencesMessage::ToggleShowStoragePreferences.into()),
],
vec![
MenuListEntry::new("Print Trace Logs")
Expand Down
Loading
Loading