mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-10-09 04:28:11 +08:00
Make the data model use Item and List types universally, with nodes authored as rank-polymorphic kernels (#4335)
* Add rank polymorphism node audit classifying all 271 nodes
* Implement StaticType for Item<T>
* Generate Item and mapped List wire variants for nodes declaring an Item<T> primary input
* Migrate nine nodes to Item element-wise kernels, dissolving the blending trait boilerplate
* Document the Item kernel implementation and staging plan
* Route Item<Vector> through TaggedValue::TypeDefault
* Add executor integration tests covering the Item and List wire variants
* Collapse element-wise Item/List wire pairs to the List form for conversion insertion
* Migrate sixteen vector modifier nodes to Item element-wise kernels
* Migrate Sample Image, Extend Image to Bounds, and Dehaze to Item element-wise kernels
* Fix bevel_with_transform test to actually exercise the transform attribute
* Implement From<T> for Item<T>
* Register PromoteNode rank adapters wrapping bare values into Item wires
* Insert PromoteNode adapters for Item/List wire pair fields in the preprocessor
* Define a real promote node backing the PromoteNode registry identifiers
* Zip ranked Item connectors by frame slot in the mapped element-wise variant
* Register ItemToListNode singleton raise adapters
* Resolve Item wires against List connectors by inserting promotion adapters at construction
* Rank the Offset Points distance connector and prove mixed-rank resolution end-to-end
* Implement Clampable for Item and List wires with per-variant clamp bounds
* Rank the Round Corners radius connector, exercising hard bounds on a ranked wire
* Implement ApplyTransform for Item
* Add Item wire implementations to the Transform node, keeping rank-0 chains rank 0
* Detect element-wise nodes by lazy primary connectors declaring Output = Item
* Convert Transform to an Item kernel with ranked parameters, delivering the broadcast milestone
* Rename Apply Transform to Bake Transform, baking item transforms on Vector, DAffine2, and DVec2
* Promote bare wires onto Item connectors at resolution via WrapItemNode adapters
* Rank the numeric, vector, and boolean parameters across the migrated element-wise nodes
* Rank the enum, integer, and seed parameters, registering their rank adapters via a consolidated macro
* Amend the audit with the DashPattern value type resolution
* Migrate the string family to Item element-wise kernels
* Unwrap Item wires into bare legacy connectors at resolution via UnwrapItemNode adapters
* Shadow owned node parameters in bodies instead of mut in signatures
* Migrate the math family and string measure nodes to Item element-wise kernels
* Convert the comparison and clamp nodes to Item kernels, dropping unreachable &str rows
* Flat-map expander kernels returning List under the mapped variant's frame
* Migrate the expander nodes to Item kernels flat-mapping under the frame
* Remove the unused peel_list helper
* Rank the raster adjustment and blending kernels, recontextualizing shader nodes onto an Item stand-in
Migrate the 16 adjustment nodes, Mix, Color Overlay, and Gradient Map from whole-List kernels to rank-0 Item kernels, letting the macro derive the List-mapped (zip) variants. Move the Adjust and Blend per-element seams off List onto the element types (add the Raster<CPU> impls, drop the now-dead List impls).
Shader nodes keep their bodies verbatim: PerPixelAdjust re-emits the identical kernel against a transparent no_std Item stand-in, so every Item<T> connector and .element() call resolves to a zero-cost identity on the GPU while the uniform buffer stays bare repr(C). The macro peels Item off ranked uniform params, wraps the fetched texel and uniforms at the entry point, and unwraps the result. This drops the shader_node/Item incompatibility guard. Register rank adapters for the adjustment enums.
* Update the rank polymorphism roadmap for the landed shader-node and adjustments chunk
* Rename the GPU Item stand-in to ShaderItem, aliased as Item at its shader-node import sites
* Flip the vector shape generators to emit rank-0 Item<Vector>
The shape generators (Rectangle, Circle, Ellipse, Arc, Spiral, Polygon, Star, Arrow, Line, Grid, QR Code) each produced exactly one shape wrapped in a singleton List<Vector>. Emit Item<Vector> directly so they connect to the rank-0 content connector of the migrated Transform node. Downstream List consumers receive the value through the existing Item to List promotion.
Relax the element-wise validation so a `()` (generator) primary may return Item<T> without being element-wise. Adapt the Repeat on Points test, which still takes a List content connector, by raising the generator's Item output through a singleton wrapper node.
* Parse ranked Item<T> parameter defaults against the bare element type
A ranked `Item<T>` parameter's default value is a bare, unranked `T` (promoted to the wire at resolution), but the preprocessor was handed the wrapped `Item<T>` type and could not parse the literal, flooding the console with warnings and dropping the defaults. Key the field's default_type metadata off the peeled element type for concrete ranked parameters, leaving generic `Item<T>` primaries and skip_impl nodes untouched.
* Parse an element-wise primary's scalar default against the bare element type
An element-wise node's primary reports its default_type as the List wire form so an unconnected primary defaults to an empty list. But when the primary carries a scalar `#[default]` (such as Root's radicand), that literal must parse as a bare element, not a List. Key the primary's default_type off the bare element type when it has a Default value source, keeping the List form otherwise.
* Add the DashPattern value type for stroke dash sequences
Introduce a rank-0 DashPattern value type (a Vec<f64> of alternating dash and gap lengths) so a stroke's dash pattern is a single frameable value rather than a rank-1 List<f64>. Register it as an auto-generated TaggedValue variant, parse its default from a comma or space separated string, and register its rank adapters. Not yet wired into the Stroke node.
* Rank the Fill and Stroke nodes element-wise and give Stroke a DashPattern connector
Migrate Fill and Stroke to element-wise Item<V> primaries (over Vector and Graphic element types) via a new element-level VectorItemMut trait, so styling one shape yields one shape and rank is preserved instead of promoting the input to a singleton List and emitting a List. The macro derives the List-mapped variant for genuine collections.
Wire the Stroke dash sequence to the new rank-0 DashPattern value type, collapsing the old content x paint x dash cartesian and dropping the IntoF64Vec trait. Update the stroke properties dash widget, the drawing tool, and graph-operation plumbing to read and write DashPattern, and migrate legacy F64Array, F64, and String dash inputs on document open.
Assign Colors stays a whole-collection node: each element's gradient position depends on its index among all siblings, which the element frame does not expose, so it keeps its List primary and the VectorListIterMut trait.
* Register rank adapters for the ranked Stroke enum parameters
The element-wise Stroke node ranks its align, cap, and paint order parameters as Item<StrokeAlign>, Item<StrokeCap>, and Item<PaintOrder>, but those enums lacked promotion adapters, so a bare default enum value could not be promoted to its Item wire and no Stroke variant resolved ("No construct found for node"). Register their rank adapters alongside StrokeJoin.
* Display Item wires in the Data panel without a List's ID column
Add a TableItemLayout impl for Item<T> and recognize Item wire types when introspecting graph data. An Item holds a single element, so it renders as a one-row table of the element plus its attributes with no leading index column, and it labels as its element type T rather than a List's T[]. Add ItemAttributeValues::get_any for the attribute widget dispatch.
* Register MonitorNode for Item wire types so the Data panel introspects them directly
Graph introspection wraps the inspected output in a generic MonitorNode typed to the wire. Without Item<T> monitor registrations, an Item<Vector> output could only be monitored after an Item to List promotion, so the Data panel captured and displayed a List<Vector> despite the connector being Item<Vector>. Register monitors for the Item types the element-wise nodes emit, and add the matching Data panel downcast entries.
* Color and double Item/List wires and cleave layer-stack connectors in the node graph
* Route wire color and rank through hidden nodes and refresh them on type changes
* Rework the DashPattern connector conversions with element-wise promotion and an explicit reducer node
* Rank the remaining value, context, aggregation, and transform nodes onto Item<T> wires
* Back DashPattern with a List<f64> so the Data panel can introspect its lengths
* Carry a single Item<T> through varargs so the Read context nodes emit Item<T> not List<T>
* Relax rank validation for aggregation shapes, add element adapters, and match variants by fewest promotions
* Rank the remaining bare and unnecessarily-List connectors across the node catalog
* Add Graphic::None and the FillChoice paint value, making colors and gradients plain values
* Rename GradientStops to Gradient and the legacy Gradient/Fill structs to LegacyGradient/LegacyFill
* Restore generator frame-from-params ranking to the roadmap as a planned stage
* Rename the ranked-field adapter identifier from PromoteNode to FieldAdapterNode to reflect its full contract
* Unload only the wires whose displayed style changed when types update
* Peel wire rank in the editor's semantic type checks so rank-0 layers are recognized
* Restore the whole-List Transform variant so rank-1 content wires resolve again
* Register the Item wire forms for the Memoize and Context Modification infrastructure nodes
* Give every ranked connector a field adapter and add numeric cast variants for legacy wires
* Key a ranked param's type default off its Item wire form when no literal default exists
* Inherit the layer's content value when splicing a node into an empty chain
* Migrate stale List-form TypeDefault inputs to the definition's current default
* Generate the mapped wire variant only when the element-wise node has a frame source
* Let a bare wire feed a List connector via a wrap-raise adapter, costed as two rank steps
* Add a zip companion to the whole-List Transform so ranked List parameters pair per slot
* Add the Sum, Average, Minimum, Maximum, Any, and All list reducers
* Convert the measure family to element-wise Item kernels per the audit classification
* Prefer the bare element value over the Item type default so ranked params keep their widgets
* Rename GradientStopsUI to GradientUI
* Split Fill's optional transform into a _has_transform bool and a ranked _transform matrix
* Rename the migration-only OptionalDAffine2 TaggedValue to LegacyOptionalDAffine2
* Flow byte buffers as Item<Resource> instead of List<u8> across the byte nodes
* Macro-generate the list-content wire variant, retiring the hand-written Transform-zip, Area, and Centroid companions
* Let ()-primary generators take ranked params and frame over them via the mapped variant, ranking Circle's radius
* Rank the vector shape generators' params to Item, adding a rank-aware input grab to the introspection harness
* Rank the value, color, and text generator params to Item
* Rank the raster, web-request, and context-reader generator params to Item
* Fix the repeat and brush test wirings left behind by the param-ranking sweeps
* Delete the vestigial Some, Unwrap Option, and Size Of debug nodes
* Delete the Attach Attribute node, folding its role into Write Attribute
* Add the Filter and Sort list companion nodes
* Guard the removed-definition migration swap target with a test
* Add the Box Corners value type in place of the rectangle corner radius list
* Split Text to Vector's per-glyph mode into a Text to Vector Glyphs node
* Rank the Combine Channels node's channel connectors to Item
* Make Map Points an element-wise node
* Delete the deprecated Upload Texture node
* Update the implementation roadmap to reflect the landed stages
* Let monitor introspection read rank-0 wires, locking in the layer coercion promotion path
* Prefer the rank-0 default when disconnecting a rank-capable input
* Make Path Modify an element-wise node
* Wrap node paths in a NodeIdPath newtype so they flow as a single Item
* Give Item<Raster<CPU>> a default so an unconnected Brush background resolves
* Stop the Brush node from setting layer attributes its paint operation doesn't produce
* Present-gate Flatten Path's adopted layer path like its fill and stroke
* Gate carried layer attributes on static column presence, not runtime values
* Give the remaining graphic Item<T> types a default so unconnected primaries resolve
* Dispatch a ranked param's Properties widget from its rank-0 element type
* Make Extract Transform an element-wise node, restoring the Origins to Polyline body
* Rename Flatten Path to Combine Paths
* Stamp Legacy Layer Extend's adopted layer path as a readable NodeIdPath
* Drop the dead List<u8> and List<NodeId> wire rows
* Rank Flatten Graphic's Fully Flatten toggle to Item
* Update the implementation roadmap with the endgame scope
* Make Combine Paths a reducer that collapses the whole frame into one path
* Stop type-converter nodes from carrying the source's unrelated attributes
* Format the Origins to Polyline regression test
* Wrap the Brush node's trace in a BrushTrace newtype so it flows as one value
* Make Switch a framed element-wise select, bundling whole collections
* Widen and align element-type coverage across the list and graphic nodes
* Register the compiler's cache chain pair for every ranked enum and newtype wire
* Fix wire colors for Passthrough outputs, bundled lists, and bools, and widen list wires
* Represent List wire types structurally with Type::List, replacing name-parsed rank promotion
* Treat scope and data fields as environment, rank scope wires as Item, and feed the render boundary through a context vararg
* Delete the vestigial Clone debug node
* Reinstate Upload Texture as an element-wise node and fix the GPU variants' scope executor and rank adapters
* Rename Combine Paths back to Flatten Path, deferring that rename to its own PR
* Deduplicate the promotion adapter registrations into the field adapter macro
* Rank Write Attribute's value connector to Item<AttributeValueDyn>, retiring the UnwrapItem bridge
* Vertical wire styling
* Store the editor layer path attribute as a bare NodeIdPath, not an Item<NodeIdPath>
* Rank Context Modification's features connector to Item<ContextFeatures>, dropping the dead memoize row
* Rank Path Modify's modification parameter to Item<Box<VectorModification>>
* Rename the field adapter node family to input adapter
* Drop the dead bare scalar rows from Context Modification's implementations list
* Move the dynamic executor's test module into its own file
* Drop the registry's unreachable bare rows for Memoize, the cache chain, and ConvertNode
* Materialize stored TaggedValues as ranked Item wires at the source
* Remove the bare-wire promotion and adapter machinery made dead by ranked value materialization
* Plant the input adapter for List-only inputs, composing position conversion from standard rows
* Consolidate Into/Convert conversions into the input adapter umbrella and rename the rank adapter identifiers
* Fix grouped layers gaining a phantom None stack element from the FillChoice default hijacking every List<Graphic> disconnect
* Enforce ranked node inputs in the macro, rejecting bare wire declarations
* Remove the unit Context => () machinery rows, leaving () purely as the no-primary sentinel
* Add a --signatures rank-audit mode to node-docs for the ranked-wire migration
* Remove the node-docs --signatures rank-audit mode now that ranked wires are enforced
* Migrate legacy no-color values on the Black & White, Color Overlay, and Empty Image color inputs
* Rewrite the element-wise accessor wire type at the primary input, not raw index 0
* Register the cache chain for Resource wires, replacing the lone hand-written Monitor row
* Gate the remaining Raster<GPU> registry rows behind the gpu feature
* Let List<DVec2> wires erase to ListDyn for the attribute reader and element counter
* Rename Extract Element to Item at Index, Count Elements to List Length, and Omit Element to Remove at Index
* Store paint picks as plain color/gradient values, removing the FillChoice value type
* Code review restructuring
* Sort by the consumed sort_key attribute or natural element order, adding the Sort Key node
* Remove the new list-combinator and reducer nodes to defer them to a follow-up PR
* Parse Fill and Stroke color defaults through the paint wire's Graphic element
* Emit ranked implementation-row default types structurally so their element TypeIds survive to default-literal parsing
* Exempt the deliberate no-paint choice from the stale List-form TypeDefault migration
* Migrate the legacy 4-input Fill directly to the split has-transform shape
* Upgrade the demo artwork
* Fix the valid AI review findings: Item eq/hash contract, table-era no-paint migration, quantize List rows, and other smaller issues
* Remove the rank polymorphism working documents
* Hash Item attribute values directly instead of debug-formatting them, speeding up cached evaluation
* Replace the data panel's dead bare-wire downcast arms with full coverage of the ranked monitor row types
* Derive PartialEq for Item now that attributes participate in equality
* Extend the data panel's attribute dispatchers with the newly supported scalar and choice enum types
* Add List monitor rows for the framed numeric conversion outputs so inspecting them resolves, with matching data panel arms
This commit is contained in:
@@ -0,0 +1,696 @@
|
||||
use rand::Rng;
|
||||
use rfd::AsyncFileDialog;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{Receiver, SyncSender};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::dpi::{PhysicalPosition, PhysicalSize};
|
||||
use winit::event::{ButtonSource, ElementState, MouseButton, StartCause, WindowEvent};
|
||||
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
||||
use winit::window::WindowId;
|
||||
|
||||
use crate::dirs;
|
||||
use crate::event::{AppEvent, AppEventScheduler};
|
||||
use crate::persist;
|
||||
use crate::preferences;
|
||||
use crate::render::{RenderError, RenderState};
|
||||
use crate::ui::{UiCommand, UiInstance};
|
||||
use crate::window::Window;
|
||||
use crate::wrapper::messages::{DesktopFrontendMessage, DesktopWrapperMessage, InputMessage, MouseKeys, MouseState, Preferences};
|
||||
use crate::wrapper::{DesktopWrapper, MmapResourceStorage, NodeGraphExecutionResult, WgpuContext, serialize_frontend_messages};
|
||||
|
||||
pub(crate) struct App {
|
||||
render_state: Option<RenderState>,
|
||||
wgpu_context: WgpuContext,
|
||||
window: Option<Window>,
|
||||
window_scale: f64,
|
||||
window_size: PhysicalSize<u32>,
|
||||
window_maximized: bool,
|
||||
window_fullscreen: bool,
|
||||
window_pending_drag: bool,
|
||||
pointer_position: PhysicalPosition<f64>,
|
||||
pointer_lock_position: Option<PhysicalPosition<f64>>,
|
||||
ui_scale: f64,
|
||||
app_event_receiver: Receiver<AppEvent>,
|
||||
app_event_scheduler: AppEventScheduler,
|
||||
desktop_wrapper: DesktopWrapper,
|
||||
ui: UiInstance,
|
||||
ui_frame_received: bool,
|
||||
start_render_sender: SyncSender<()>,
|
||||
web_communication_initialized: bool,
|
||||
web_communication_startup_buffer: Vec<Vec<u8>>,
|
||||
preferences: Preferences,
|
||||
launch_documents: Option<Vec<PathBuf>>,
|
||||
startup_time: Option<Instant>,
|
||||
exiting: Arc<AtomicBool>,
|
||||
exit_reason: ExitReason,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn init() {
|
||||
Window::init();
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
ui: UiInstance,
|
||||
wgpu_context: WgpuContext,
|
||||
app_event_receiver: Receiver<AppEvent>,
|
||||
app_event_scheduler: AppEventScheduler,
|
||||
preferences: Preferences,
|
||||
launch_documents: Vec<PathBuf>,
|
||||
) -> Self {
|
||||
let ctrlc_app_event_scheduler = app_event_scheduler.clone();
|
||||
ctrlc::set_handler(move || {
|
||||
tracing::info!("Termination signal received, exiting...");
|
||||
ctrlc_app_event_scheduler.schedule(AppEvent::Exit);
|
||||
})
|
||||
.expect("Error setting Ctrl-C handler");
|
||||
|
||||
let exiting = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let rendering_app_event_scheduler = app_event_scheduler.clone();
|
||||
let (start_render_sender, start_render_receiver) = std::sync::mpsc::sync_channel(1);
|
||||
let exiting_clone = exiting.clone();
|
||||
std::thread::spawn(move || {
|
||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||
loop {
|
||||
let result = runtime.block_on(DesktopWrapper::execute_node_graph());
|
||||
rendering_app_event_scheduler.schedule(AppEvent::NodeGraphExecutionResult(result));
|
||||
let _ = start_render_receiver.recv_timeout(Duration::from_millis(10));
|
||||
if exiting_clone.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let resource_storage = MmapResourceStorage::new(dirs::app_resources_dir()).expect("Failed to initialize on-disk resource storage");
|
||||
|
||||
// Wake the winit event loop when an editor future completes.
|
||||
let wake_scheduler = app_event_scheduler.clone();
|
||||
let wake = Arc::new(move || {
|
||||
wake_scheduler.schedule(AppEvent::DesktopWrapperMessage(DesktopWrapperMessage::Wake));
|
||||
});
|
||||
let desktop_wrapper = DesktopWrapper::new(rand::rng().random(), Arc::new(resource_storage), dirs::app_autosave_documents_dir(), wgpu_context.clone(), wake);
|
||||
|
||||
let completion_render_sender = start_render_sender.clone();
|
||||
DesktopWrapper::set_completion_notifier(move || {
|
||||
let _ = completion_render_sender.try_send(());
|
||||
});
|
||||
|
||||
Self {
|
||||
render_state: None,
|
||||
wgpu_context,
|
||||
window: None,
|
||||
window_scale: 1.,
|
||||
window_size: PhysicalSize { width: 0, height: 0 },
|
||||
window_maximized: false,
|
||||
window_fullscreen: false,
|
||||
window_pending_drag: false,
|
||||
pointer_position: Default::default(),
|
||||
pointer_lock_position: Default::default(),
|
||||
ui_scale: 1.,
|
||||
app_event_receiver,
|
||||
app_event_scheduler,
|
||||
desktop_wrapper,
|
||||
ui,
|
||||
ui_frame_received: false,
|
||||
start_render_sender,
|
||||
web_communication_initialized: false,
|
||||
web_communication_startup_buffer: Vec::new(),
|
||||
preferences,
|
||||
launch_documents: Some(launch_documents),
|
||||
startup_time: None,
|
||||
exiting,
|
||||
exit_reason: ExitReason::Shutdown,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run(mut self, event_loop: EventLoop) -> ExitReason {
|
||||
event_loop.run_app(&mut self).unwrap();
|
||||
self.exit_reason
|
||||
}
|
||||
|
||||
fn exit(&mut self, reason: Option<ExitReason>) {
|
||||
if self.exiting.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let _ = self.start_render_sender.send(());
|
||||
if let Some(reason) = reason {
|
||||
self.exit_reason = reason;
|
||||
}
|
||||
self.app_event_scheduler.schedule(AppEvent::Exit);
|
||||
}
|
||||
|
||||
fn resize(&mut self) {
|
||||
let Some(window) = &self.window else {
|
||||
tracing::error!("Resize failed due to missing window");
|
||||
return;
|
||||
};
|
||||
|
||||
let maximized = window.is_maximized();
|
||||
if maximized != self.window_maximized {
|
||||
self.window_maximized = maximized;
|
||||
self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(DesktopWrapperMessage::UpdateMaximized { maximized }));
|
||||
}
|
||||
|
||||
let fullscreen = window.is_fullscreen();
|
||||
if fullscreen != self.window_fullscreen {
|
||||
self.window_fullscreen = fullscreen;
|
||||
self.app_event_scheduler
|
||||
.schedule(AppEvent::DesktopWrapperMessage(DesktopWrapperMessage::UpdateFullscreen { fullscreen }));
|
||||
}
|
||||
|
||||
let size = window.surface_size();
|
||||
let scale = window.scale_factor() * self.ui_scale;
|
||||
let is_new_size = size != self.window_size;
|
||||
let is_new_scale = scale != self.window_scale;
|
||||
|
||||
if !is_new_size && !is_new_scale {
|
||||
return;
|
||||
}
|
||||
|
||||
if is_new_size {
|
||||
self.ui.send(UiCommand::Resized {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
});
|
||||
}
|
||||
if is_new_scale {
|
||||
self.ui.send(UiCommand::ScaleChanged(scale));
|
||||
}
|
||||
|
||||
self.ui.send(UiCommand::Refresh);
|
||||
|
||||
if let Some(render_state) = &mut self.render_state {
|
||||
render_state.resize(size.width, size.height);
|
||||
}
|
||||
|
||||
window.request_redraw();
|
||||
|
||||
self.window_size = size;
|
||||
self.window_scale = scale;
|
||||
}
|
||||
|
||||
fn handle_desktop_frontend_message(&mut self, message: DesktopFrontendMessage, responses: &mut Vec<DesktopWrapperMessage>) {
|
||||
match message {
|
||||
DesktopFrontendMessage::ToWeb(messages) => {
|
||||
let Some(bytes) = serialize_frontend_messages(messages) else {
|
||||
tracing::error!("Failed to serialize frontend messages");
|
||||
return;
|
||||
};
|
||||
self.send_or_queue_web_message(bytes);
|
||||
}
|
||||
DesktopFrontendMessage::OpenFileDialog { title, filters, multiple, context } => {
|
||||
let app_event_scheduler = self.app_event_scheduler.clone();
|
||||
let _ = thread::spawn(move || {
|
||||
let mut dialog = AsyncFileDialog::new().set_title(title);
|
||||
for filter in filters {
|
||||
dialog = dialog.add_filter(filter.name, &filter.extensions);
|
||||
}
|
||||
|
||||
let handles = if multiple {
|
||||
futures::executor::block_on(dialog.pick_files()).unwrap_or_default()
|
||||
} else {
|
||||
futures::executor::block_on(dialog.pick_file()).into_iter().collect()
|
||||
};
|
||||
|
||||
for handle in handles {
|
||||
let path = handle.path().to_path_buf();
|
||||
match fs::read(&path) {
|
||||
Ok(content) => {
|
||||
let message = DesktopWrapperMessage::FileDialogResult { path, content, context };
|
||||
app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message));
|
||||
}
|
||||
Err(e) => tracing::error!("Failed to read file {}: {}", path.display(), e),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
DesktopFrontendMessage::SaveFileDialog {
|
||||
title,
|
||||
default_filename,
|
||||
default_folder,
|
||||
filters,
|
||||
context,
|
||||
} => {
|
||||
let app_event_scheduler = self.app_event_scheduler.clone();
|
||||
let _ = thread::spawn(move || {
|
||||
let mut dialog = AsyncFileDialog::new().set_title(title).set_file_name(default_filename);
|
||||
if let Some(folder) = default_folder {
|
||||
dialog = dialog.set_directory(folder);
|
||||
}
|
||||
for filter in filters {
|
||||
dialog = dialog.add_filter(filter.name, &filter.extensions);
|
||||
}
|
||||
|
||||
let show_dialog = async move { dialog.save_file().await.map(|f| f.path().to_path_buf()) };
|
||||
|
||||
if let Some(path) = futures::executor::block_on(show_dialog) {
|
||||
let message = DesktopWrapperMessage::SaveFileDialogResult { path, context };
|
||||
app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message));
|
||||
}
|
||||
});
|
||||
}
|
||||
DesktopFrontendMessage::WriteFile { path, content } => {
|
||||
if let Err(e) = fs::write(&path, content) {
|
||||
tracing::error!("Failed to write file {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
DesktopFrontendMessage::OpenUrl(url) => {
|
||||
let _ = thread::spawn(move || {
|
||||
if let Err(e) = open::that(&url) {
|
||||
tracing::error!("Failed to open URL: {}: {}", url, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
DesktopFrontendMessage::UpdateViewportPhysicalBounds { x, y, width, height } => {
|
||||
if let Some(render_state) = &mut self.render_state
|
||||
&& let Some(window) = &self.window
|
||||
{
|
||||
let window_size = window.surface_size();
|
||||
|
||||
let viewport_offset_x = x / window_size.width as f64;
|
||||
let viewport_offset_y = y / window_size.height as f64;
|
||||
render_state.set_viewport_offset([viewport_offset_x as f32, viewport_offset_y as f32]);
|
||||
|
||||
let viewport_scale_x = if width != 0. { window_size.width as f64 / width } else { 1. };
|
||||
let viewport_scale_y = if height != 0. { window_size.height as f64 / height } else { 1. };
|
||||
render_state.set_viewport_scale([viewport_scale_x as f32, viewport_scale_y as f32]);
|
||||
}
|
||||
}
|
||||
DesktopFrontendMessage::UpdateUIScale { scale } => {
|
||||
self.ui_scale = scale;
|
||||
self.resize();
|
||||
}
|
||||
DesktopFrontendMessage::UpdateOverlays(scene) => {
|
||||
if let Some(render_state) = &mut self.render_state {
|
||||
render_state.set_overlays_scene(scene);
|
||||
}
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
DesktopFrontendMessage::PersistenceWriteState { state } => {
|
||||
persist::write_state(state);
|
||||
}
|
||||
DesktopFrontendMessage::PersistenceReadState => {
|
||||
responses.push(DesktopWrapperMessage::LoadPersistedState { state: persist::read_state() });
|
||||
}
|
||||
DesktopFrontendMessage::PersistenceReadDocument { id } => {
|
||||
if let Some(document) = persist::read_document_content(&id) {
|
||||
responses.push(DesktopWrapperMessage::LoadDocumentContent { id, document });
|
||||
} else {
|
||||
tracing::error!("Failed to read document content for {id:?}");
|
||||
}
|
||||
}
|
||||
DesktopFrontendMessage::PersistenceWriteDocument { id, document_serialized_content } => {
|
||||
persist::write_document_content(id, document_serialized_content);
|
||||
}
|
||||
DesktopFrontendMessage::PersistenceDeleteDocument { id } => {
|
||||
persist::delete_document(&id);
|
||||
}
|
||||
DesktopFrontendMessage::PersistenceWritePreferences { preferences } => {
|
||||
preferences::write(preferences);
|
||||
}
|
||||
DesktopFrontendMessage::PersistenceLoadPreferences => {
|
||||
let preferences = preferences::read();
|
||||
let message = DesktopWrapperMessage::LoadPreferences { preferences };
|
||||
responses.push(message);
|
||||
}
|
||||
DesktopFrontendMessage::OpenLaunchDocuments => {
|
||||
let Some(launch_documents) = std::mem::take(&mut self.launch_documents) else {
|
||||
tracing::error!("OpenLaunchDocuments should only be sent once");
|
||||
return;
|
||||
};
|
||||
self.app_event_scheduler.schedule(AppEvent::OpenFiles(launch_documents));
|
||||
}
|
||||
DesktopFrontendMessage::UpdateMenu { entries } => {
|
||||
if let Some(window) = &self.window {
|
||||
window.update_menu(entries);
|
||||
}
|
||||
}
|
||||
DesktopFrontendMessage::ClipboardRead => {
|
||||
if let Some(window) = &self.window {
|
||||
let content = window.clipboard_read();
|
||||
let message = DesktopWrapperMessage::ClipboardReadResult { content };
|
||||
self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message));
|
||||
}
|
||||
}
|
||||
DesktopFrontendMessage::ClipboardWrite { content } => {
|
||||
if let Some(window) = &mut self.window {
|
||||
window.clipboard_write(content);
|
||||
}
|
||||
}
|
||||
DesktopFrontendMessage::PointerLock => {
|
||||
self.pointer_lock_position = Some(self.pointer_position);
|
||||
if let Some(window) = &self.window {
|
||||
window.start_pointer_lock();
|
||||
}
|
||||
}
|
||||
DesktopFrontendMessage::WindowClose => {
|
||||
self.app_event_scheduler.schedule(AppEvent::Exit);
|
||||
}
|
||||
DesktopFrontendMessage::WindowMinimize => {
|
||||
if let Some(window) = &self.window {
|
||||
window.minimize();
|
||||
}
|
||||
}
|
||||
DesktopFrontendMessage::WindowMaximize => {
|
||||
if let Some(window) = &self.window {
|
||||
window.toggle_maximize();
|
||||
}
|
||||
}
|
||||
DesktopFrontendMessage::WindowFullscreen => {
|
||||
if let Some(window) = &mut self.window {
|
||||
window.toggle_fullscreen();
|
||||
}
|
||||
}
|
||||
DesktopFrontendMessage::WindowDrag => {
|
||||
self.window_pending_drag = true;
|
||||
}
|
||||
DesktopFrontendMessage::WindowFocus => {
|
||||
if let Some(window) = &self.window {
|
||||
window.focus();
|
||||
}
|
||||
}
|
||||
DesktopFrontendMessage::WindowHide => {
|
||||
if let Some(window) = &self.window {
|
||||
window.hide();
|
||||
}
|
||||
}
|
||||
DesktopFrontendMessage::WindowHideOthers => {
|
||||
if let Some(window) = &self.window {
|
||||
window.hide_others();
|
||||
}
|
||||
}
|
||||
DesktopFrontendMessage::WindowShowAll => {
|
||||
if let Some(window) = &self.window {
|
||||
window.show_all();
|
||||
}
|
||||
}
|
||||
DesktopFrontendMessage::Restart => {
|
||||
self.exit(Some(ExitReason::Restart));
|
||||
}
|
||||
DesktopFrontendMessage::LoadThirdPartyLicenses => {
|
||||
let compressed = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/third-party-licenses.txt.xz"));
|
||||
let mut reader = lzma_rust2::XzReader::new(compressed.as_slice(), false);
|
||||
let mut text = String::new();
|
||||
if let Err(e) = reader.read_to_string(&mut text) {
|
||||
tracing::error!("Failed to decompress third-party licenses: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let message = DesktopWrapperMessage::LoadThirdPartyLicenses { text };
|
||||
responses.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_desktop_frontend_messages(&mut self, messages: Vec<DesktopFrontendMessage>) {
|
||||
let mut responses = Vec::new();
|
||||
for message in messages {
|
||||
self.handle_desktop_frontend_message(message, &mut responses);
|
||||
}
|
||||
for message in responses {
|
||||
self.dispatch_desktop_wrapper_message(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_desktop_wrapper_message(&mut self, message: DesktopWrapperMessage) {
|
||||
let responses = self.desktop_wrapper.dispatch(message);
|
||||
self.handle_desktop_frontend_messages(responses);
|
||||
}
|
||||
|
||||
fn send_or_queue_web_message(&mut self, message: Vec<u8>) {
|
||||
if self.web_communication_initialized {
|
||||
self.ui.send(UiCommand::Message(message));
|
||||
} else {
|
||||
self.web_communication_startup_buffer.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn user_event(&mut self, event_loop: &dyn ActiveEventLoop, event: AppEvent) {
|
||||
match event {
|
||||
AppEvent::WebCommunicationInitialized => {
|
||||
self.web_communication_initialized = true;
|
||||
for message in self.web_communication_startup_buffer.drain(..) {
|
||||
self.ui.send(UiCommand::Message(message));
|
||||
}
|
||||
}
|
||||
AppEvent::DesktopWrapperMessage(message) => self.dispatch_desktop_wrapper_message(message),
|
||||
AppEvent::NodeGraphExecutionResult(result) => match result {
|
||||
NodeGraphExecutionResult::HasRun(texture) => {
|
||||
self.dispatch_desktop_wrapper_message(DesktopWrapperMessage::PollNodeGraphEvaluation);
|
||||
if let Some(texture) = texture
|
||||
&& let Some(render_state) = self.render_state.as_mut()
|
||||
&& let Some(window) = self.window.as_ref()
|
||||
{
|
||||
render_state.bind_viewport_texture(texture);
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
NodeGraphExecutionResult::NotRun => {}
|
||||
},
|
||||
AppEvent::UiUpdate(texture) => {
|
||||
if let Some(render_state) = self.render_state.as_mut() {
|
||||
render_state.bind_ui_texture(texture);
|
||||
}
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
if !self.ui_frame_received {
|
||||
self.ui_frame_received = true;
|
||||
}
|
||||
}
|
||||
AppEvent::CursorChange(cursor) => {
|
||||
if let Some(window) = &mut self.window {
|
||||
window.set_cursor(event_loop, cursor);
|
||||
}
|
||||
}
|
||||
AppEvent::Exit => {
|
||||
tracing::info!("Exiting main event loop");
|
||||
event_loop.exit();
|
||||
}
|
||||
AppEvent::UiCrashed => {
|
||||
tracing::error!("UI process crashed, exiting.");
|
||||
self.exit(Some(ExitReason::Shutdown));
|
||||
}
|
||||
AppEvent::OpenFiles(paths) => {
|
||||
// Accumulate launch documents until OpenLaunchDocuments message is received
|
||||
if let Some(launch_documents) = &mut self.launch_documents {
|
||||
launch_documents.extend(paths);
|
||||
return;
|
||||
}
|
||||
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let app_event_scheduler = self.app_event_scheduler.clone();
|
||||
let _ = thread::spawn(move || {
|
||||
for path in paths {
|
||||
tracing::info!("Opening file: {}", path.display());
|
||||
if let Ok(content) = fs::read(&path) {
|
||||
let message = DesktopWrapperMessage::OpenFile { path, content };
|
||||
app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message));
|
||||
} else {
|
||||
tracing::error!("Failed to read file: {}", path.display());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
AppEvent::MenuEvent { id } => {
|
||||
self.dispatch_desktop_wrapper_message(DesktopWrapperMessage::MenuEvent { id });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ApplicationHandler for App {
|
||||
fn can_create_surfaces(&mut self, event_loop: &dyn ActiveEventLoop) {
|
||||
let window = Window::new(event_loop, self.app_event_scheduler.clone());
|
||||
self.window = Some(window);
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let present_mode = None;
|
||||
#[cfg(target_os = "macos")]
|
||||
let present_mode = if !self.preferences.vsync { Some(wgpu::PresentMode::Immediate) } else { None };
|
||||
|
||||
let render_state = RenderState::new(self.window.as_ref().unwrap(), self.wgpu_context.clone(), present_mode);
|
||||
self.render_state = Some(render_state);
|
||||
|
||||
if let Some(window) = &self.window.as_ref() {
|
||||
window.show();
|
||||
}
|
||||
|
||||
self.resize();
|
||||
|
||||
self.startup_time = Some(Instant::now());
|
||||
}
|
||||
|
||||
fn proxy_wake_up(&mut self, event_loop: &dyn ActiveEventLoop) {
|
||||
while let Ok(event) = self.app_event_receiver.try_recv() {
|
||||
self.user_event(event_loop, event);
|
||||
}
|
||||
}
|
||||
|
||||
fn window_event(&mut self, _event_loop: &dyn ActiveEventLoop, _window_id: WindowId, event: WindowEvent) {
|
||||
// Handle pointer lock release
|
||||
if let Some(pointer_lock_position) = self.pointer_lock_position
|
||||
&& let WindowEvent::PointerButton {
|
||||
state: ElementState::Released,
|
||||
button: ButtonSource::Mouse(MouseButton::Left),
|
||||
..
|
||||
} = event
|
||||
{
|
||||
self.pointer_lock_position = None;
|
||||
if let Some(window) = &self.window {
|
||||
window.end_pointer_lock();
|
||||
}
|
||||
self.ui.send(UiCommand::Input(WindowEvent::PointerMoved {
|
||||
device_id: None,
|
||||
position: pointer_lock_position,
|
||||
primary: true,
|
||||
source: winit::event::PointerSource::Mouse,
|
||||
}));
|
||||
}
|
||||
|
||||
self.ui.send(UiCommand::Input(event.clone()));
|
||||
|
||||
match event {
|
||||
WindowEvent::CloseRequested => {
|
||||
self.app_event_scheduler.schedule(AppEvent::Exit);
|
||||
}
|
||||
WindowEvent::SurfaceResized(_) | WindowEvent::ScaleFactorChanged { .. } => {
|
||||
self.resize();
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
#[cfg(target_os = "macos")]
|
||||
self.resize();
|
||||
|
||||
let Some(render_state) = &mut self.render_state else { return };
|
||||
if let Some(window) = &self.window {
|
||||
if !window.can_render() {
|
||||
return;
|
||||
}
|
||||
|
||||
match render_state.render(window) {
|
||||
Ok(_) => {}
|
||||
Err(RenderError::OutdatedUITextureError) => {
|
||||
self.ui.send(UiCommand::Refresh);
|
||||
}
|
||||
Err(RenderError::SurfaceLost) => {
|
||||
tracing::warn!("lost surface");
|
||||
}
|
||||
Err(other) => tracing::error!("Render error: {:?}", other),
|
||||
}
|
||||
let _ = self.start_render_sender.try_send(());
|
||||
}
|
||||
|
||||
if !self.ui_frame_received
|
||||
&& !self.preferences.disable_ui_acceleration
|
||||
&& self.web_communication_initialized
|
||||
&& let Some(startup_time) = self.startup_time
|
||||
&& startup_time.elapsed() > Duration::from_secs(3)
|
||||
{
|
||||
tracing::error!("UI acceleration not working, exiting.");
|
||||
self.exit(Some(ExitReason::UiAccelerationFailure));
|
||||
}
|
||||
}
|
||||
WindowEvent::DragDropped { paths, .. } => {
|
||||
for path in paths {
|
||||
match fs::read(&path) {
|
||||
Ok(content) => {
|
||||
let message = DesktopWrapperMessage::ImportFile { path, content };
|
||||
self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to read dropped file {}: {}", path.display(), e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Forward and Back buttons are not supported by CEF and thus need to be directly forwarded the editor
|
||||
WindowEvent::PointerButton {
|
||||
button: ButtonSource::Mouse(button),
|
||||
state: ElementState::Pressed,
|
||||
..
|
||||
} => {
|
||||
let mouse_keys = match button {
|
||||
MouseButton::Back => Some(MouseKeys::BACK),
|
||||
MouseButton::Forward => Some(MouseKeys::FORWARD),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(mouse_keys) = mouse_keys {
|
||||
let message = DesktopWrapperMessage::Input(InputMessage::PointerDown {
|
||||
editor_mouse_state: MouseState { mouse_keys, ..Default::default() },
|
||||
modifier_keys: Default::default(),
|
||||
});
|
||||
self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message));
|
||||
|
||||
let message = DesktopWrapperMessage::Input(InputMessage::PointerUp {
|
||||
editor_mouse_state: Default::default(),
|
||||
modifier_keys: Default::default(),
|
||||
});
|
||||
self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message));
|
||||
}
|
||||
}
|
||||
|
||||
WindowEvent::PointerMoved { position, .. } | WindowEvent::PointerLeft { position: Some(position), .. } | WindowEvent::PointerEntered { position, .. }
|
||||
if self.pointer_lock_position.is_none() =>
|
||||
{
|
||||
self.pointer_position = position;
|
||||
|
||||
if self.window_pending_drag {
|
||||
self.window_pending_drag = false;
|
||||
if let Some(window) = &self.window {
|
||||
window.start_drag();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WindowEvent::PointerButton {
|
||||
button: ButtonSource::Mouse(MouseButton::Left),
|
||||
state: ElementState::Released,
|
||||
..
|
||||
} => {
|
||||
self.window_pending_drag = false;
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn device_event(&mut self, _event_loop: &dyn ActiveEventLoop, _device_id: Option<winit::event::DeviceId>, event: winit::event::DeviceEvent) {
|
||||
if self.pointer_lock_position.is_some()
|
||||
&& let winit::event::DeviceEvent::PointerMotion { delta: (x, y) } = event
|
||||
{
|
||||
let message = DesktopWrapperMessage::PointerLockMove { x, y };
|
||||
self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message));
|
||||
}
|
||||
}
|
||||
|
||||
fn new_events(&mut self, _event_loop: &dyn ActiveEventLoop, cause: winit::event::StartCause) {
|
||||
if let StartCause::ResumeTimeReached { .. } = cause
|
||||
&& let Some(window) = &self.window
|
||||
{
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, event_loop: &dyn ActiveEventLoop) {
|
||||
event_loop.set_control_flow(ControlFlow::WaitUntil(Instant::now() + Duration::from_millis(10)));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum ExitReason {
|
||||
Shutdown,
|
||||
Restart,
|
||||
UiAccelerationFailure,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#[derive(clap::Parser)]
|
||||
#[clap(name = "graphite", version)]
|
||||
pub struct Cli {
|
||||
#[arg(help = "Files to open on startup")]
|
||||
pub files: Vec<std::path::PathBuf>,
|
||||
|
||||
#[arg(long, action = clap::ArgAction::SetTrue, help = "Disable hardware accelerated UI rendering")]
|
||||
pub disable_ui_acceleration: bool,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
pub(crate) const APP_NAME: &str = "Graphite";
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub(crate) const APP_ID: &str = "art.graphite.Graphite";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) const APP_DIRECTORY_NAME: &str = "graphite";
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(crate) const APP_DIRECTORY_NAME: &str = "Graphite";
|
||||
pub(crate) const APP_LOCK_FILE_NAME: &str = "instance.lock";
|
||||
pub(crate) const APP_SOCKET_FILE_NAME: &str = "instance.sock";
|
||||
pub(crate) const APP_STATE_FILE_NAME: &str = "state.ron";
|
||||
pub(crate) const APP_PREFERENCES_FILE_NAME: &str = "preferences.ron";
|
||||
pub(crate) const APP_DOCUMENTS_DIRECTORY_NAME: &str = "documents";
|
||||
pub(crate) const APP_RESOURCES_DIRECTORY_NAME: &str = "resources";
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::consts::{APP_DIRECTORY_NAME, APP_DOCUMENTS_DIRECTORY_NAME, APP_RESOURCES_DIRECTORY_NAME};
|
||||
|
||||
pub(crate) fn ensure_dir_exists(path: &PathBuf) {
|
||||
if !path.exists() {
|
||||
fs::create_dir_all(path).unwrap_or_else(|_| panic!("Failed to create directory at {path:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear_dir(path: &PathBuf) {
|
||||
let Ok(entries) = fs::read_dir(path) else {
|
||||
tracing::error!("Failed to read directory at {path:?}");
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let entry_path = entry.path();
|
||||
if entry_path.is_dir() {
|
||||
if let Err(e) = fs::remove_dir_all(&entry_path) {
|
||||
tracing::error!("Failed to remove directory at {:?}: {}", entry_path, e);
|
||||
}
|
||||
} else if entry_path.is_file() {
|
||||
if let Err(e) = fs::remove_file(&entry_path) {
|
||||
tracing::error!("Failed to remove file at {:?}: {}", entry_path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn app_data_dir() -> PathBuf {
|
||||
let path = dirs::data_dir().expect("Failed to get data directory").join(APP_DIRECTORY_NAME);
|
||||
ensure_dir_exists(&path);
|
||||
path
|
||||
}
|
||||
|
||||
pub(crate) fn app_autosave_documents_dir() -> PathBuf {
|
||||
let path = app_data_dir().join(APP_DOCUMENTS_DIRECTORY_NAME);
|
||||
ensure_dir_exists(&path);
|
||||
path
|
||||
}
|
||||
|
||||
pub(crate) fn app_resources_dir() -> PathBuf {
|
||||
let path = app_data_dir().join(APP_RESOURCES_DIRECTORY_NAME);
|
||||
ensure_dir_exists(&path);
|
||||
path
|
||||
}
|
||||
|
||||
// TODO: Eventually remove this cleanup code for the old "browser" CEF directory
|
||||
pub(crate) fn delete_old_cef_browser_directory() {
|
||||
let old_browser_dir = crate::dirs::app_data_dir().join("browser");
|
||||
if old_browser_dir.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(&old_browser_dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use crate::ui::Cursor;
|
||||
use crate::wrapper::NodeGraphExecutionResult;
|
||||
use crate::wrapper::messages::DesktopWrapperMessage;
|
||||
|
||||
pub(crate) enum AppEvent {
|
||||
UiUpdate(wgpu::Texture),
|
||||
CursorChange(Cursor),
|
||||
WebCommunicationInitialized,
|
||||
DesktopWrapperMessage(DesktopWrapperMessage),
|
||||
NodeGraphExecutionResult(NodeGraphExecutionResult),
|
||||
Exit,
|
||||
UiCrashed,
|
||||
OpenFiles(Vec<std::path::PathBuf>),
|
||||
#[cfg(target_os = "macos")]
|
||||
MenuEvent {
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AppEventScheduler {
|
||||
pub(crate) proxy: winit::event_loop::EventLoopProxy,
|
||||
pub(crate) sender: std::sync::mpsc::Sender<AppEvent>,
|
||||
}
|
||||
|
||||
impl AppEventScheduler {
|
||||
pub(crate) fn schedule(&self, event: AppEvent) {
|
||||
let _ = self.sender.send(event);
|
||||
self.proxy.wake_up();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait CreateAppEventSchedulerEventLoopExt {
|
||||
fn create_app_event_scheduler(&self, sender: std::sync::mpsc::Sender<AppEvent>) -> AppEventScheduler;
|
||||
}
|
||||
|
||||
impl CreateAppEventSchedulerEventLoopExt for winit::event_loop::EventLoop {
|
||||
fn create_app_event_scheduler(&self, sender: std::sync::mpsc::Sender<AppEvent>) -> AppEventScheduler {
|
||||
AppEventScheduler { proxy: self.create_proxy(), sender }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::wrapper::{WgpuContext, WgpuContextBuilder, WgpuFeatures};
|
||||
|
||||
pub(super) async fn create_wgpu_context() -> WgpuContext {
|
||||
let mut wgpu_context_builder = WgpuContextBuilder::new().with_features(WgpuFeatures::IMMEDIATES);
|
||||
|
||||
// TODO: make this configurable via cli flags instead
|
||||
if let Some(index) = std::env::var("GRAPHITE_WGPU_ADAPTER").ok().and_then(|s| s.parse().ok()) {
|
||||
tracing::info!("Overriding WGPU adapter selection with adapter index {index}");
|
||||
wgpu_context_builder = wgpu_context_builder.with_selection(index);
|
||||
}
|
||||
|
||||
// TODO: add a cli flag to list adapters and exit instead of always printing
|
||||
println!("\nAvailable WGPU adapters:\n{}", wgpu_context_builder.available_adapters_fmt().await);
|
||||
|
||||
let wgpu_context = wgpu_context_builder.build().await.expect("Failed to create WGPU context");
|
||||
|
||||
// TODO: add a cli flag to list adapters and exit instead of always printing
|
||||
println!("Using WGPU adapter: {:?}", wgpu_context.adapter.get_info());
|
||||
|
||||
wgpu_context
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
use crate::app::App;
|
||||
use crate::cli::Cli;
|
||||
use crate::consts::APP_LOCK_FILE_NAME;
|
||||
use crate::event::{AppEvent, CreateAppEventSchedulerEventLoopExt};
|
||||
use clap::Parser;
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use ui::{Acceleration, UiConfig, UiContext, UiEvent, UiSetupResult};
|
||||
use winit::event_loop::EventLoop;
|
||||
|
||||
pub(crate) use graphite_desktop_ui as ui;
|
||||
pub(crate) use graphite_desktop_wrapper as wrapper;
|
||||
|
||||
mod app;
|
||||
mod cli;
|
||||
mod dirs;
|
||||
mod event;
|
||||
mod gpu_context;
|
||||
mod persist;
|
||||
mod preferences;
|
||||
mod render;
|
||||
mod socket;
|
||||
mod window;
|
||||
|
||||
pub(crate) mod consts;
|
||||
|
||||
pub fn start() -> ExitCode {
|
||||
tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env()).init();
|
||||
|
||||
let ui_context = match UiContext::setup() {
|
||||
UiSetupResult::Ready(context) => context,
|
||||
UiSetupResult::Helper(code) => return code,
|
||||
UiSetupResult::Failed => {
|
||||
tracing::error!("Failed to set up the UI runtime");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
let Ok(lock_file) = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(dirs::app_data_dir().join(APP_LOCK_FILE_NAME))
|
||||
else {
|
||||
tracing::error!("Failed to open lock file.");
|
||||
return ExitCode::FAILURE;
|
||||
};
|
||||
let mut lock = fd_lock::RwLock::new(lock_file);
|
||||
let lock = match lock.try_write() {
|
||||
Ok(mut guard) => {
|
||||
tracing::info!("Acquired application lock");
|
||||
let _ = guard.set_len(0);
|
||||
let _ = write!(guard, "{}", std::process::id());
|
||||
let _ = guard.sync_all();
|
||||
guard
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::error!("Another instance is already running, Exiting.");
|
||||
if !cli.files.is_empty()
|
||||
&& let Err(error) = socket::send(socket::Message::OpenFiles(cli.files))
|
||||
{
|
||||
tracing::error!("Failed to send socket message to running instance: {}", error);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
};
|
||||
|
||||
dirs::clear_dir(&ui::temp_dir_root());
|
||||
|
||||
// TODO: Eventually remove this cleanup code for the old "browser" CEF directory
|
||||
dirs::delete_old_cef_browser_directory();
|
||||
|
||||
let mut prefs = preferences::read();
|
||||
|
||||
// Must be called before event loop initialization or native window integrations will break
|
||||
App::init();
|
||||
|
||||
let wgpu_context = futures::executor::block_on(gpu_context::create_wgpu_context());
|
||||
|
||||
let event_loop = EventLoop::new().unwrap();
|
||||
let (app_event_sender, app_event_receiver) = std::sync::mpsc::channel();
|
||||
let app_event_scheduler = event_loop.create_app_event_scheduler(app_event_sender);
|
||||
|
||||
let _socket_handle = socket::start(app_event_scheduler.clone());
|
||||
|
||||
if cli.disable_ui_acceleration {
|
||||
prefs.disable_ui_acceleration = true;
|
||||
}
|
||||
if prefs.disable_ui_acceleration {
|
||||
println!("UI acceleration is disabled");
|
||||
}
|
||||
|
||||
let acceleration = if prefs.disable_ui_acceleration { Acceleration::Disabled } else { Acceleration::Auto };
|
||||
let ui_context = match ui_context.start(UiConfig { acceleration }) {
|
||||
Ok(context) => context,
|
||||
Err(error) => {
|
||||
tracing::error!("Failed to start the UI runtime: {error}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
let ui = match ui_context.instance(&wgpu_context.device, &wgpu_context.queue) {
|
||||
Ok(ui) => ui,
|
||||
Err(error) => {
|
||||
tracing::error!("Failed to start the UI: {error}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
tracing::info!("UI runtime started successfully");
|
||||
|
||||
{
|
||||
let ui = ui.clone();
|
||||
let scheduler = app_event_scheduler.clone();
|
||||
let spawned = std::thread::Builder::new().name("ui-events".to_string()).spawn(move || {
|
||||
while let Some(event) = ui.recv() {
|
||||
match event {
|
||||
UiEvent::Ready => scheduler.schedule(AppEvent::WebCommunicationInitialized),
|
||||
UiEvent::Frame(texture) => scheduler.schedule(AppEvent::UiUpdate(texture)),
|
||||
UiEvent::Cursor(cursor) => scheduler.schedule(AppEvent::CursorChange(cursor)),
|
||||
UiEvent::Message(message) => match wrapper::deserialize_editor_message(&message) {
|
||||
Some(message) => scheduler.schedule(AppEvent::DesktopWrapperMessage(message)),
|
||||
None => tracing::error!("Failed to deserialize web message"),
|
||||
},
|
||||
UiEvent::Failure(error) => {
|
||||
tracing::error!("UI failure: {error}");
|
||||
scheduler.schedule(AppEvent::UiCrashed);
|
||||
}
|
||||
UiEvent::Crashed => scheduler.schedule(AppEvent::UiCrashed),
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(error) = spawned {
|
||||
tracing::error!("Failed to spawn the UI event bridge thread: {error}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
let app = App::new(ui.clone(), wgpu_context, app_event_receiver, app_event_scheduler, prefs, cli.files);
|
||||
|
||||
let exit_reason = app.run(event_loop);
|
||||
|
||||
// ui needs to be shutdown before restarting
|
||||
ui.shutdown();
|
||||
|
||||
// If exiting due to a UI acceleration failure, update preferences to disable it for next launch
|
||||
if matches!(exit_reason, app::ExitReason::UiAccelerationFailure) {
|
||||
tracing::error!("Disabling UI acceleration");
|
||||
preferences::modify(|prefs| {
|
||||
prefs.disable_ui_acceleration = true;
|
||||
});
|
||||
}
|
||||
|
||||
// Explicitly drop the instance lock
|
||||
drop(lock);
|
||||
|
||||
match exit_reason {
|
||||
app::ExitReason::Restart | app::ExitReason::UiAccelerationFailure => {
|
||||
tracing::info!("Restarting application");
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
#[cfg(target_family = "unix")]
|
||||
let _ = std::os::unix::process::CommandExt::exec(&mut command);
|
||||
#[cfg(target_family = "unix")]
|
||||
tracing::error!("Failed to restart application");
|
||||
#[cfg(not(target_family = "unix"))]
|
||||
let _ = command.spawn();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() -> std::process::ExitCode {
|
||||
graphite_desktop::start()
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use crate::wrapper::messages::{DocumentId, PersistedState};
|
||||
|
||||
pub(crate) fn read_state() -> PersistedState {
|
||||
let path = state_file_path();
|
||||
let data = match std::fs::read_to_string(&path) {
|
||||
Ok(d) => d,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tracing::info!("No persistent data file found at {path:?}, starting fresh");
|
||||
return PersistedState::default();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to read persistent data from disk: {e}");
|
||||
return PersistedState::default();
|
||||
}
|
||||
};
|
||||
let loaded = match ron::from_str(&data) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to deserialize persistent data: {e}");
|
||||
return PersistedState::default();
|
||||
}
|
||||
};
|
||||
|
||||
garbage_collect_document_files(&loaded);
|
||||
loaded
|
||||
}
|
||||
|
||||
pub(crate) fn write_state(state: PersistedState) {
|
||||
let state: &PersistedState = &state;
|
||||
let data = match ron::ser::to_string_pretty(state, Default::default()) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to serialize persistent data: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = std::fs::write(state_file_path(), data) {
|
||||
tracing::error!("Failed to write persistent data to disk: {e}");
|
||||
}
|
||||
garbage_collect_document_files(state);
|
||||
}
|
||||
|
||||
pub(crate) fn write_document_content(id: DocumentId, document_content: String) {
|
||||
if let Err(e) = std::fs::write(document_content_path(&id), document_content) {
|
||||
tracing::error!("Failed to write document {id:?} to disk: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_document_content(id: &DocumentId) -> Option<String> {
|
||||
std::fs::read_to_string(document_content_path(id)).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn delete_document(id: &DocumentId) {
|
||||
if let Err(e) = std::fs::remove_file(document_content_path(id)) {
|
||||
tracing::error!("Failed to delete document {id:?} from disk: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn garbage_collect_document_files(state: &PersistedState) {
|
||||
let valid_paths: std::collections::HashSet<_> = state.documents.iter().map(|doc| document_content_path(&doc.id)).collect();
|
||||
|
||||
let directory = crate::dirs::app_autosave_documents_dir();
|
||||
let entries = match std::fs::read_dir(&directory) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to read autosave documents directory: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() && !valid_paths.contains(&path) {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
tracing::error!("Failed to remove orphaned document file {path:?}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn state_file_path() -> std::path::PathBuf {
|
||||
let mut path = crate::dirs::app_data_dir();
|
||||
path.push(crate::consts::APP_STATE_FILE_NAME);
|
||||
path
|
||||
}
|
||||
|
||||
fn document_content_path(id: &DocumentId) -> std::path::PathBuf {
|
||||
let mut path = crate::dirs::app_autosave_documents_dir();
|
||||
path.push(format!("{:x}.{}", id.0, graphite_desktop_wrapper::FILE_EXTENSION));
|
||||
path
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use graphite_desktop_wrapper::messages::Preferences;
|
||||
|
||||
pub(crate) fn write(preferences: Preferences) {
|
||||
let Ok(preferences) = ron::ser::to_string_pretty(&preferences, Default::default()) else {
|
||||
tracing::error!("Failed to serialize preferences");
|
||||
return;
|
||||
};
|
||||
std::fs::write(file_path(), &preferences).unwrap_or_else(|e| {
|
||||
tracing::error!("Failed to write preferences to disk: {e}");
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn read() -> Preferences {
|
||||
let Ok(data) = std::fs::read_to_string(file_path()) else {
|
||||
return Preferences::default();
|
||||
};
|
||||
let Ok(preferences) = ron::from_str(&data) else {
|
||||
return Preferences::default();
|
||||
};
|
||||
preferences
|
||||
}
|
||||
|
||||
pub(crate) fn modify(f: impl FnOnce(&mut Preferences)) {
|
||||
let mut preferences = read();
|
||||
f(&mut preferences);
|
||||
write(preferences);
|
||||
}
|
||||
|
||||
fn file_path() -> std::path::PathBuf {
|
||||
let mut path = crate::dirs::app_data_dir();
|
||||
path.push(crate::consts::APP_PREFERENCES_FILE_NAME);
|
||||
path
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
mod state;
|
||||
pub(crate) use state::{RenderError, RenderState};
|
||||
@@ -0,0 +1,121 @@
|
||||
// =============
|
||||
// VERTEX SHADER
|
||||
// =============
|
||||
|
||||
struct VertexOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) tex_coords: vec2<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
|
||||
var out: VertexOutput;
|
||||
let pos = array(
|
||||
vec2f(-1.0, -1.0),
|
||||
vec2f(3.0, -1.0),
|
||||
vec2f(-1.0, 3.0),
|
||||
);
|
||||
let xy = pos[vertex_index];
|
||||
out.clip_position = vec4f(xy, 0.0, 1.0);
|
||||
let coords = xy / 2. + 0.5;
|
||||
out.tex_coords = vec2f(coords.x, 1. - coords.y);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ===============
|
||||
// FRAGMENT SHADER
|
||||
// ===============
|
||||
|
||||
struct Immediates {
|
||||
viewport_scale: vec2<f32>,
|
||||
viewport_offset: vec2<f32>,
|
||||
ui_scale: vec2<f32>,
|
||||
background_color: vec4<f32>,
|
||||
};
|
||||
|
||||
var<immediate> immediates: Immediates;
|
||||
|
||||
@group(0) @binding(0)
|
||||
var t_viewport: texture_2d<f32>;
|
||||
@group(0) @binding(1)
|
||||
var t_overlays: texture_2d<f32>;
|
||||
@group(0) @binding(2)
|
||||
var t_ui: texture_2d<f32>;
|
||||
@group(0) @binding(3)
|
||||
var s_diffuse: sampler;
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let ui_coordinate = in.tex_coords * immediates.ui_scale;
|
||||
if (ui_coordinate.x < 0.0 || ui_coordinate.x > 1.0 ||
|
||||
ui_coordinate.y < 0.0 || ui_coordinate.y > 1.0) {
|
||||
return srgb_to_linear(immediates.background_color);
|
||||
}
|
||||
|
||||
let ui_linear = srgb_to_linear(textureSample(t_ui, s_diffuse, ui_coordinate));
|
||||
if (ui_linear.a >= 0.999) {
|
||||
return ui_linear;
|
||||
}
|
||||
|
||||
// UI texture is premultiplied, we need to unpremultiply before blending
|
||||
let ui_srgb = linear_to_srgb(unpremultiply(ui_linear));
|
||||
|
||||
let viewport_coordinate = (in.tex_coords - immediates.viewport_offset) * immediates.viewport_scale;
|
||||
if (viewport_coordinate.x < 0.0 || viewport_coordinate.x > 1.0 ||
|
||||
viewport_coordinate.y < 0.0 || viewport_coordinate.y > 1.0) {
|
||||
return srgb_to_linear(immediates.background_color);
|
||||
}
|
||||
|
||||
let overlay_srgb = textureSample(t_overlays, s_diffuse, viewport_coordinate);
|
||||
var viewport_srgb = textureSample(t_viewport, s_diffuse, viewport_coordinate);
|
||||
|
||||
if (viewport_srgb.a < 0.001) {
|
||||
viewport_srgb = immediates.background_color;
|
||||
} else if (viewport_srgb.a < 0.999) {
|
||||
viewport_srgb = blend(viewport_srgb, immediates.background_color);
|
||||
}
|
||||
|
||||
if (overlay_srgb.a < 0.001) {
|
||||
if (ui_srgb.a < 0.001) {
|
||||
return srgb_to_linear(viewport_srgb);
|
||||
} else {
|
||||
return srgb_to_linear(blend(ui_srgb, viewport_srgb));
|
||||
}
|
||||
}
|
||||
|
||||
let composite_linear = blend(srgb_to_linear(overlay_srgb), srgb_to_linear(viewport_srgb));
|
||||
|
||||
if (ui_srgb.a < 0.001) {
|
||||
return composite_linear;
|
||||
}
|
||||
|
||||
return srgb_to_linear(blend(ui_srgb, linear_to_srgb(composite_linear)));
|
||||
}
|
||||
|
||||
fn blend(fg: vec4<f32>, bg: vec4<f32>) -> vec4<f32> {
|
||||
let a = fg.a + bg.a * (1.0 - fg.a);
|
||||
let rgb = fg.rgb * fg.a + bg.rgb * bg.a * (1.0 - fg.a);
|
||||
return vec4<f32>(rgb, a);
|
||||
}
|
||||
|
||||
fn linear_to_srgb(in: vec4<f32>) -> vec4<f32> {
|
||||
let cutoff = vec3<f32>(0.0031308);
|
||||
let lo = in.rgb * 12.92;
|
||||
let hi = 1.055 * pow(max(in.rgb, vec3<f32>(0.0)), vec3<f32>(1.0/2.4)) - 0.055;
|
||||
return vec4<f32>(select(lo, hi, in.rgb > cutoff), in.a);
|
||||
}
|
||||
|
||||
fn srgb_to_linear(in: vec4<f32>) -> vec4<f32> {
|
||||
let cutoff = vec3<f32>(0.04045);
|
||||
let lo = in.rgb / 12.92;
|
||||
let hi = pow((in.rgb + 0.055) / 1.055, vec3<f32>(2.4));
|
||||
return vec4<f32>(select(lo, hi, in.rgb > cutoff), in.a);
|
||||
}
|
||||
|
||||
fn unpremultiply(in: vec4<f32>) -> vec4<f32> {
|
||||
if (in.a > 0.0) {
|
||||
return vec4<f32>((in.rgb / in.a), in.a);
|
||||
} else {
|
||||
return vec4<f32>(0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
use wgpu::PresentMode;
|
||||
|
||||
use crate::window::Window;
|
||||
use crate::wrapper::{WgpuContext, WgpuCurrentSurfaceTexture, WgpuExecutor, WgpuSurface};
|
||||
|
||||
#[derive(derivative::Derivative)]
|
||||
#[derivative(Debug)]
|
||||
pub(crate) struct RenderState {
|
||||
surface: WgpuSurface,
|
||||
executor: WgpuExecutor,
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
render_pipeline: wgpu::RenderPipeline,
|
||||
transparent_texture: std::sync::Arc<wgpu::Texture>,
|
||||
sampler: wgpu::Sampler,
|
||||
desired_width: u32,
|
||||
desired_height: u32,
|
||||
viewport_scale: [f32; 2],
|
||||
viewport_offset: [f32; 2],
|
||||
viewport_texture: Option<std::sync::Arc<wgpu::Texture>>,
|
||||
overlays_texture: Option<std::sync::Arc<wgpu::Texture>>,
|
||||
ui_texture: Option<wgpu::Texture>,
|
||||
bind_group: Option<wgpu::BindGroup>,
|
||||
#[derivative(Debug = "ignore")]
|
||||
overlays_scene: Option<vello::Scene>,
|
||||
surface_outdated: bool,
|
||||
}
|
||||
|
||||
impl RenderState {
|
||||
pub(crate) fn new(window: &Window, context: WgpuContext, present_mode: Option<PresentMode>) -> Self {
|
||||
let size = window.surface_size();
|
||||
let surface = window.create_surface(&context.instance);
|
||||
|
||||
let surface_caps = surface.get_capabilities(&context.adapter);
|
||||
let surface_format = surface_caps.formats.iter().find(|f| f.is_srgb()).copied().unwrap_or(surface_caps.formats[0]);
|
||||
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
format: surface_format,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
present_mode: present_mode.unwrap_or(surface_caps.present_modes[0]),
|
||||
alpha_mode: surface_caps.alpha_modes[0],
|
||||
view_formats: vec![],
|
||||
desired_maximum_frame_latency: 1,
|
||||
};
|
||||
|
||||
surface.configure(&context.device, &config);
|
||||
|
||||
let transparent_texture = std::sync::Arc::new(context.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Transparent Texture"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Bgra8UnormSrgb,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
}));
|
||||
|
||||
// Create shader module
|
||||
let shader = context.device.create_shader_module(wgpu::include_wgsl!("composite_shader.wgsl"));
|
||||
|
||||
// Create sampler
|
||||
let sampler = context.device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let texture_bind_group_layout = context.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
multisampled: false,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
multisampled: false,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
multisampled: false,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 3,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
label: Some("texture_bind_group_layout"),
|
||||
});
|
||||
|
||||
let render_pipeline_layout = context.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Render Pipeline Layout"),
|
||||
bind_group_layouts: &[Some(&texture_bind_group_layout)],
|
||||
immediate_size: size_of::<Immediates>() as u32,
|
||||
});
|
||||
|
||||
let render_pipeline = context.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Render Pipeline"),
|
||||
layout: Some(&render_pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_main"),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format,
|
||||
blend: Some(wgpu::BlendState::REPLACE),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
strip_index_format: None,
|
||||
front_face: wgpu::FrontFace::Ccw,
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
polygon_mode: wgpu::PolygonMode::Fill,
|
||||
unclipped_depth: false,
|
||||
conservative: false,
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: 1,
|
||||
mask: !0,
|
||||
alpha_to_coverage_enabled: false,
|
||||
},
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
let executor = WgpuExecutor::with_context(context).expect("Failed to create WgpuExecutor");
|
||||
|
||||
Self {
|
||||
surface,
|
||||
executor,
|
||||
config,
|
||||
render_pipeline,
|
||||
transparent_texture,
|
||||
sampler,
|
||||
desired_width: size.width,
|
||||
desired_height: size.height,
|
||||
viewport_scale: [1., 1.],
|
||||
viewport_offset: [0., 0.],
|
||||
viewport_texture: None,
|
||||
overlays_texture: None,
|
||||
ui_texture: None,
|
||||
bind_group: None,
|
||||
overlays_scene: None,
|
||||
surface_outdated: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resize(&mut self, width: u32, height: u32) {
|
||||
if width == self.desired_width && height == self.desired_height {
|
||||
return;
|
||||
}
|
||||
|
||||
self.desired_width = width;
|
||||
self.desired_height = height;
|
||||
self.surface_outdated = true;
|
||||
}
|
||||
|
||||
pub(crate) fn bind_viewport_texture(&mut self, viewport_texture: std::sync::Arc<wgpu::Texture>) {
|
||||
self.viewport_texture = Some(viewport_texture);
|
||||
self.update_bindgroup();
|
||||
}
|
||||
|
||||
pub(crate) fn bind_ui_texture(&mut self, ui_texture: wgpu::Texture) {
|
||||
if self.ui_texture.as_ref() == Some(&ui_texture) {
|
||||
self.surface_outdated = true;
|
||||
return;
|
||||
}
|
||||
self.ui_texture = Some(ui_texture);
|
||||
self.update_bindgroup();
|
||||
}
|
||||
|
||||
pub(crate) fn set_viewport_scale(&mut self, scale: [f32; 2]) {
|
||||
self.surface_outdated = true;
|
||||
self.viewport_scale = scale;
|
||||
}
|
||||
|
||||
pub(crate) fn set_viewport_offset(&mut self, offset: [f32; 2]) {
|
||||
self.surface_outdated = true;
|
||||
self.viewport_offset = offset;
|
||||
}
|
||||
|
||||
pub(crate) fn set_overlays_scene(&mut self, scene: vello::Scene) {
|
||||
self.surface_outdated = true;
|
||||
self.overlays_scene = Some(scene);
|
||||
}
|
||||
|
||||
fn render_overlays(&mut self, scene: vello::Scene) {
|
||||
let Some(viewport_texture) = self.viewport_texture.as_ref() else {
|
||||
tracing::warn!("No viewport texture bound, cannot render overlays");
|
||||
return;
|
||||
};
|
||||
let size = glam::UVec2::new(viewport_texture.width(), viewport_texture.height());
|
||||
let result = self.executor.render_vello_scene(&scene, size, &Default::default(), None);
|
||||
match result {
|
||||
Ok(texture) => {
|
||||
self.overlays_texture = Some(texture.into());
|
||||
}
|
||||
Err(e) => {
|
||||
self.overlays_texture = None;
|
||||
tracing::error!("Error rendering overlays: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
self.update_bindgroup();
|
||||
}
|
||||
|
||||
pub(crate) fn render(&mut self, window: &Window) -> Result<(), RenderError> {
|
||||
if !self.surface_outdated {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Apply resize once per presented frame.
|
||||
if self.desired_width > 0 && self.desired_height > 0 && (self.config.width != self.desired_width || self.config.height != self.desired_height) {
|
||||
self.config.width = self.desired_width;
|
||||
self.config.height = self.desired_height;
|
||||
self.surface.configure(&self.executor.context().device, &self.config);
|
||||
}
|
||||
|
||||
let ui_scale = if let Some(ui_texture) = &self.ui_texture
|
||||
&& (self.desired_width != ui_texture.width() || self.desired_height != ui_texture.height())
|
||||
{
|
||||
Some([self.desired_width as f32 / ui_texture.width() as f32, self.desired_height as f32 / ui_texture.height() as f32])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(scene) = self.overlays_scene.take() {
|
||||
self.render_overlays(scene);
|
||||
}
|
||||
|
||||
let (surface_texture, suboptimal) = match self.surface.get_current_texture(&self.executor.context().queue) {
|
||||
WgpuCurrentSurfaceTexture::Success(t) => (t, false),
|
||||
WgpuCurrentSurfaceTexture::Suboptimal(t) => (t, true),
|
||||
WgpuCurrentSurfaceTexture::Occluded => return Ok(()),
|
||||
WgpuCurrentSurfaceTexture::Lost => return Err(RenderError::SurfaceLost),
|
||||
WgpuCurrentSurfaceTexture::Outdated => return Err(RenderError::SurfaceOutdated),
|
||||
WgpuCurrentSurfaceTexture::Timeout => return Err(RenderError::SurfaceTimeout),
|
||||
WgpuCurrentSurfaceTexture::Validation => return Err(RenderError::SurfaceValidation),
|
||||
};
|
||||
|
||||
let view = surface_texture.texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
let mut encoder = self.executor.context().device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("Render Encoder") });
|
||||
|
||||
{
|
||||
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Graphite Composition Render Pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.01, g: 0.01, b: 0.01, a: 1. }),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
depth_slice: None,
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
occlusion_query_set: None,
|
||||
timestamp_writes: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
|
||||
render_pass.set_pipeline(&self.render_pipeline);
|
||||
render_pass.set_immediates(
|
||||
0,
|
||||
bytemuck::bytes_of(&Immediates {
|
||||
viewport_scale: self.viewport_scale,
|
||||
viewport_offset: self.viewport_offset,
|
||||
ui_scale: ui_scale.unwrap_or([1., 1.]),
|
||||
_pad: [0., 0.],
|
||||
background_color: [0x22 as f32 / 0xff as f32, 0x22 as f32 / 0xff as f32, 0x22 as f32 / 0xff as f32, 1.], // #222222
|
||||
}),
|
||||
);
|
||||
if let Some(bind_group) = &self.bind_group {
|
||||
render_pass.set_bind_group(0, bind_group, &[]);
|
||||
render_pass.draw(0..3, 0..1); // Draw 3 vertices for fullscreen triangle
|
||||
} else {
|
||||
tracing::warn!("No bind group available - showing clear color only");
|
||||
}
|
||||
}
|
||||
surface_texture.queue.submit(std::iter::once(encoder.finish()));
|
||||
window.pre_present_notify();
|
||||
surface_texture.present();
|
||||
|
||||
if suboptimal {
|
||||
self.surface.configure(&self.executor.context().device, &self.config);
|
||||
}
|
||||
|
||||
if ui_scale.is_some() {
|
||||
return Err(RenderError::OutdatedUITextureError);
|
||||
}
|
||||
self.surface_outdated = false;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_bindgroup(&mut self) {
|
||||
self.surface_outdated = true;
|
||||
let viewport_texture_view = self.viewport_texture.as_ref().unwrap_or(&self.transparent_texture).create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let overlays_texture_view = self.overlays_texture.as_ref().unwrap_or(&self.transparent_texture).create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let ui_texture_view = self.ui_texture.as_ref().unwrap_or(&self.transparent_texture).create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
let bind_group = self.executor.context().device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
layout: &self.render_pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&viewport_texture_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&overlays_texture_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&ui_texture_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
label: Some("texture_bind_group"),
|
||||
});
|
||||
|
||||
self.bind_group = Some(bind_group);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum RenderError {
|
||||
OutdatedUITextureError,
|
||||
SurfaceLost,
|
||||
SurfaceOutdated,
|
||||
SurfaceTimeout,
|
||||
SurfaceValidation,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Immediates {
|
||||
viewport_scale: [f32; 2],
|
||||
viewport_offset: [f32; 2],
|
||||
ui_scale: [f32; 2],
|
||||
_pad: [f32; 2],
|
||||
background_color: [f32; 4],
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use interprocess::local_socket::{GenericFilePath, GenericNamespaced, ListenerNonblockingMode, ListenerOptions, Name, prelude::*};
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::consts::APP_SOCKET_FILE_NAME;
|
||||
use crate::event::{AppEvent, AppEventScheduler};
|
||||
|
||||
// TODO: Needs to be integrated/replaced with the action system.
|
||||
// TODO: At that point this should just wrap the action, meaning all actions bindable by the user can also be accessed via the socket.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) enum Message {
|
||||
OpenFiles(Vec<std::path::PathBuf>),
|
||||
}
|
||||
|
||||
fn handle_message(message: Message, app_event_scheduler: &AppEventScheduler) {
|
||||
match message {
|
||||
Message::OpenFiles(paths) => {
|
||||
app_event_scheduler.schedule(AppEvent::OpenFiles(paths));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn send(message: Message) -> std::io::Result<()> {
|
||||
let data = ron::ser::to_string(&message).map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
|
||||
let mut connection = interprocess::local_socket::Stream::connect(socket_name())?;
|
||||
connection.write_all(data.as_bytes())
|
||||
}
|
||||
|
||||
pub(crate) struct SocketHandle {
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
shutdown_sender: mpsc::Sender<()>,
|
||||
}
|
||||
impl Drop for SocketHandle {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.shutdown_sender.send(());
|
||||
let _ = self.thread.take().expect("SocketHandle can only be dropped once").join();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start(app_event_scheduler: AppEventScheduler) -> SocketHandle {
|
||||
let (shutdown_sender, shutdown_receiver) = mpsc::channel();
|
||||
|
||||
let thread = thread::Builder::new()
|
||||
.name("socket".to_string())
|
||||
.spawn(move || run(app_event_scheduler, shutdown_receiver))
|
||||
.expect("Failed to spawn socket thread");
|
||||
|
||||
SocketHandle {
|
||||
shutdown_sender,
|
||||
thread: Some(thread),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(app_event_scheduler: AppEventScheduler, shutdown_receiver: mpsc::Receiver<()>) {
|
||||
let listener = match ListenerOptions::new()
|
||||
.name(socket_name())
|
||||
.nonblocking(ListenerNonblockingMode::Accept)
|
||||
.try_overwrite(true)
|
||||
.max_spin_time(Duration::from_millis(100))
|
||||
.create_sync()
|
||||
{
|
||||
Ok(listener) => listener,
|
||||
Err(error) => {
|
||||
tracing::error!("Failed to bind socket: {}", error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let max_backoff = Duration::from_millis(100);
|
||||
let mut backoff = Duration::ZERO;
|
||||
|
||||
loop {
|
||||
if backoff.is_zero() {
|
||||
match shutdown_receiver.try_recv() {
|
||||
Ok(()) | Err(mpsc::TryRecvError::Disconnected) => break,
|
||||
Err(mpsc::TryRecvError::Empty) => {}
|
||||
}
|
||||
backoff = Duration::from_nanos(1);
|
||||
} else {
|
||||
match shutdown_receiver.recv_timeout(backoff) {
|
||||
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
backoff = (backoff * 2).min(max_backoff);
|
||||
}
|
||||
|
||||
match listener.accept() {
|
||||
Ok(mut connection) => {
|
||||
backoff = Duration::ZERO;
|
||||
|
||||
let app_event_scheduler = app_event_scheduler.clone();
|
||||
let spawn_result = thread::Builder::new().name("socket-connection".to_string()).spawn(move || {
|
||||
let mut data = String::new();
|
||||
if let Err(error) = connection.read_to_string(&mut data) {
|
||||
tracing::error!("Failed to read socket message: {}", error);
|
||||
return;
|
||||
}
|
||||
|
||||
match ron::de::from_str(&data) {
|
||||
Ok(message) => handle_message(message, &app_event_scheduler),
|
||||
Err(error) => tracing::error!("Failed to deserialize socket message: {}", error),
|
||||
}
|
||||
});
|
||||
if let Err(error) = spawn_result {
|
||||
tracing::error!("Failed to spawn socket connection thread: {}", error);
|
||||
}
|
||||
}
|
||||
Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::Interrupted) => {}
|
||||
Err(error) => {
|
||||
tracing::error!("Failed to accept socket connection: {}", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn socket_name() -> Name<'static> {
|
||||
if cfg!(target_os = "windows") {
|
||||
let user = std::env::var("USERNAME").unwrap_or_default();
|
||||
let name = format!("{user}-{app}-{APP_SOCKET_FILE_NAME}", app = crate::consts::APP_NAME);
|
||||
name.to_ns_name::<GenericNamespaced>().expect("valid named pipe name")
|
||||
} else {
|
||||
crate::dirs::app_data_dir().join(APP_SOCKET_FILE_NAME).to_fs_name::<GenericFilePath>().expect("valid socket path")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
use crate::consts::APP_NAME;
|
||||
use crate::event::AppEventScheduler;
|
||||
use crate::ui::Cursor;
|
||||
use crate::wrapper::messages::MenuItem;
|
||||
use crate::wrapper::{WgpuInstance, WgpuSurface};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use winit::cursor::{CustomCursor, CustomCursorSource};
|
||||
use winit::event_loop::ActiveEventLoop;
|
||||
use winit::monitor::Fullscreen;
|
||||
use winit::window::{Window as WinitWindow, WindowAttributes};
|
||||
|
||||
pub(crate) trait NativeWindow {
|
||||
fn init() {}
|
||||
fn configure(attributes: WindowAttributes, event_loop: &dyn ActiveEventLoop) -> WindowAttributes;
|
||||
fn new(window: &dyn WinitWindow, app_event_scheduler: AppEventScheduler) -> Self;
|
||||
fn can_render(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn update_menu(&self, _entries: Vec<MenuItem>) {}
|
||||
fn hide(&self) {}
|
||||
fn hide_others(&self) {}
|
||||
fn show_all(&self) {}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "linux")]
|
||||
use linux as native;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac;
|
||||
#[cfg(target_os = "macos")]
|
||||
use mac as native;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod win;
|
||||
#[cfg(target_os = "windows")]
|
||||
use win as native;
|
||||
|
||||
pub(crate) struct Window {
|
||||
winit_window: Arc<dyn winit::window::Window>,
|
||||
#[allow(dead_code)]
|
||||
native_handle: native::NativeWindowImpl,
|
||||
custom_cursors: HashMap<CustomCursorSource, CustomCursor>,
|
||||
clipboard: Option<window_clipboard::Clipboard>,
|
||||
}
|
||||
impl Drop for Window {
|
||||
fn drop(&mut self) {
|
||||
// Clipboard must be dropped before `winit_window`
|
||||
drop(self.clipboard.take());
|
||||
}
|
||||
}
|
||||
|
||||
impl Window {
|
||||
pub(crate) fn init() {
|
||||
native::NativeWindowImpl::init();
|
||||
}
|
||||
|
||||
pub(crate) fn new(event_loop: &dyn ActiveEventLoop, app_event_scheduler: AppEventScheduler) -> Self {
|
||||
let mut attributes = WindowAttributes::default()
|
||||
.with_title(APP_NAME)
|
||||
.with_min_surface_size(winit::dpi::LogicalSize::new(400, 300))
|
||||
.with_surface_size(winit::dpi::LogicalSize::new(1200, 800))
|
||||
.with_resizable(true)
|
||||
.with_visible(false)
|
||||
.with_theme(Some(winit::window::Theme::Dark));
|
||||
|
||||
attributes = native::NativeWindowImpl::configure(attributes, event_loop);
|
||||
|
||||
let winit_window = event_loop.create_window(attributes).unwrap();
|
||||
let native_handle = native::NativeWindowImpl::new(winit_window.as_ref(), app_event_scheduler);
|
||||
let clipboard = unsafe { window_clipboard::Clipboard::connect(&winit_window) }.ok();
|
||||
Self {
|
||||
winit_window: winit_window.into(),
|
||||
native_handle,
|
||||
custom_cursors: HashMap::new(),
|
||||
clipboard,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show(&self) {
|
||||
self.winit_window.set_visible(true);
|
||||
self.winit_window.focus_window();
|
||||
}
|
||||
|
||||
pub(crate) fn request_redraw(&self) {
|
||||
self.winit_window.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn create_surface(&self, instance: &WgpuInstance) -> WgpuSurface {
|
||||
instance.create_surface(self.winit_window.clone()).expect("Failed to create surface")
|
||||
}
|
||||
|
||||
pub(crate) fn pre_present_notify(&self) {
|
||||
self.winit_window.pre_present_notify();
|
||||
}
|
||||
|
||||
pub(crate) fn can_render(&self) -> bool {
|
||||
self.native_handle.can_render()
|
||||
}
|
||||
|
||||
pub(crate) fn surface_size(&self) -> winit::dpi::PhysicalSize<u32> {
|
||||
self.winit_window.surface_size()
|
||||
}
|
||||
|
||||
pub(crate) fn scale_factor(&self) -> f64 {
|
||||
self.winit_window.scale_factor()
|
||||
}
|
||||
|
||||
pub(crate) fn minimize(&self) {
|
||||
self.winit_window.set_minimized(true);
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_maximize(&self) {
|
||||
if self.is_fullscreen() {
|
||||
return;
|
||||
}
|
||||
self.winit_window.set_maximized(!self.winit_window.is_maximized());
|
||||
}
|
||||
|
||||
pub(crate) fn is_maximized(&self) -> bool {
|
||||
self.winit_window.is_maximized()
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_fullscreen(&mut self) {
|
||||
if self.is_fullscreen() {
|
||||
self.winit_window.set_fullscreen(None);
|
||||
} else {
|
||||
self.winit_window.set_fullscreen(Some(Fullscreen::Borderless(None)));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_fullscreen(&self) -> bool {
|
||||
self.winit_window.fullscreen().is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn start_drag(&self) {
|
||||
if self.is_fullscreen() {
|
||||
return;
|
||||
}
|
||||
let _ = self.winit_window.drag_window();
|
||||
}
|
||||
|
||||
pub(crate) fn focus(&self) {
|
||||
self.winit_window.set_minimized(false);
|
||||
self.winit_window.focus_window();
|
||||
}
|
||||
|
||||
pub(crate) fn hide(&self) {
|
||||
self.native_handle.hide();
|
||||
}
|
||||
|
||||
pub(crate) fn hide_others(&self) {
|
||||
self.native_handle.hide_others();
|
||||
}
|
||||
|
||||
pub(crate) fn show_all(&self) {
|
||||
self.native_handle.show_all();
|
||||
}
|
||||
|
||||
pub(crate) fn set_cursor(&mut self, event_loop: &dyn ActiveEventLoop, cursor: Cursor) {
|
||||
let cursor = match cursor {
|
||||
Cursor::Icon(cursor_icon) => cursor_icon.into(),
|
||||
Cursor::Custom {
|
||||
rgba,
|
||||
width,
|
||||
height,
|
||||
hotspot_x,
|
||||
hotspot_y,
|
||||
} => {
|
||||
let Ok(custom_cursor_source) = CustomCursorSource::from_rgba(rgba, width, height, hotspot_x, hotspot_y) else {
|
||||
tracing::error!("Invalid custom cursor image");
|
||||
return;
|
||||
};
|
||||
let custom_cursor = match self.custom_cursors.get(&custom_cursor_source).cloned() {
|
||||
Some(cursor) => cursor,
|
||||
None => {
|
||||
let Ok(custom_cursor) = event_loop.create_custom_cursor(custom_cursor_source.clone()) else {
|
||||
tracing::error!("Failed to create custom cursor");
|
||||
return;
|
||||
};
|
||||
self.custom_cursors.insert(custom_cursor_source, custom_cursor.clone());
|
||||
custom_cursor
|
||||
}
|
||||
};
|
||||
custom_cursor.into()
|
||||
}
|
||||
Cursor::None => {
|
||||
self.winit_window.set_cursor_visible(false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.winit_window.set_cursor_visible(true);
|
||||
self.winit_window.set_cursor(cursor);
|
||||
}
|
||||
|
||||
pub(crate) fn start_pointer_lock(&self) {
|
||||
let _ = self.winit_window.set_cursor_grab(winit::window::CursorGrabMode::Locked);
|
||||
self.winit_window.set_cursor_visible(false);
|
||||
}
|
||||
|
||||
pub(crate) fn end_pointer_lock(&self) {
|
||||
let _ = self.winit_window.set_cursor_grab(winit::window::CursorGrabMode::None);
|
||||
self.winit_window.set_cursor_visible(true);
|
||||
}
|
||||
|
||||
pub(crate) fn update_menu(&self, entries: Vec<MenuItem>) {
|
||||
self.native_handle.update_menu(entries);
|
||||
}
|
||||
|
||||
pub(crate) fn clipboard_read(&self) -> Option<String> {
|
||||
let Some(clipboard) = &self.clipboard else {
|
||||
tracing::error!("Clipboard not available");
|
||||
return None;
|
||||
};
|
||||
match clipboard.read() {
|
||||
Ok(data) => Some(data),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to read from clipboard: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clipboard_write(&mut self, data: String) {
|
||||
let Some(clipboard) = &mut self.clipboard else {
|
||||
tracing::error!("Clipboard not available");
|
||||
return;
|
||||
};
|
||||
if let Err(e) = clipboard.write(data) {
|
||||
tracing::error!("Failed to write to clipboard: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use winit::event_loop::ActiveEventLoop;
|
||||
use winit::platform::wayland::ActiveEventLoopExtWayland;
|
||||
use winit::platform::wayland::WindowAttributesWayland;
|
||||
use winit::platform::x11::WindowAttributesX11;
|
||||
use winit::window::{Window, WindowAttributes};
|
||||
|
||||
use crate::consts::{APP_ID, APP_NAME};
|
||||
use crate::event::AppEventScheduler;
|
||||
|
||||
pub(super) struct NativeWindowImpl {}
|
||||
|
||||
impl super::NativeWindow for NativeWindowImpl {
|
||||
fn configure(attributes: WindowAttributes, event_loop: &dyn ActiveEventLoop) -> WindowAttributes {
|
||||
if event_loop.is_wayland() {
|
||||
let wayland_attributes = WindowAttributesWayland::default().with_name(APP_ID, "").with_prefer_csd(true);
|
||||
attributes.with_platform_attributes(Box::new(wayland_attributes))
|
||||
} else {
|
||||
let x11_attributes = WindowAttributesX11::default().with_name(APP_ID, APP_NAME);
|
||||
attributes.with_platform_attributes(Box::new(x11_attributes))
|
||||
}
|
||||
}
|
||||
|
||||
fn new(_window: &dyn Window, _app_event_scheduler: AppEventScheduler) -> Self {
|
||||
NativeWindowImpl {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use winit::event_loop::ActiveEventLoop;
|
||||
use winit::platform::macos::WindowAttributesMacOS;
|
||||
use winit::window::{Window, WindowAttributes};
|
||||
|
||||
use crate::event::AppEventScheduler;
|
||||
use crate::wrapper::messages::MenuItem;
|
||||
|
||||
mod app;
|
||||
mod menu;
|
||||
|
||||
pub(super) struct NativeWindowImpl {
|
||||
menu: menu::Menu,
|
||||
}
|
||||
|
||||
impl super::NativeWindow for NativeWindowImpl {
|
||||
fn init() {
|
||||
app::init();
|
||||
}
|
||||
|
||||
fn configure(attributes: WindowAttributes, _event_loop: &dyn ActiveEventLoop) -> WindowAttributes {
|
||||
let mac_window = WindowAttributesMacOS::default()
|
||||
.with_titlebar_transparent(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_title_hidden(true);
|
||||
attributes.with_platform_attributes(Box::new(mac_window))
|
||||
}
|
||||
|
||||
fn new(_window: &dyn Window, app_event_scheduler: AppEventScheduler) -> Self {
|
||||
app::setup(app_event_scheduler.clone());
|
||||
let menu = menu::Menu::new(app_event_scheduler);
|
||||
NativeWindowImpl { menu }
|
||||
}
|
||||
|
||||
fn update_menu(&self, entries: Vec<MenuItem>) {
|
||||
self.menu.update(entries);
|
||||
}
|
||||
|
||||
fn hide(&self) {
|
||||
app::hide();
|
||||
}
|
||||
|
||||
fn hide_others(&self) {
|
||||
app::hide_others();
|
||||
}
|
||||
|
||||
fn show_all(&self) {
|
||||
app::show_all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use std::ffi::CStr;
|
||||
use std::ffi::OsStr;
|
||||
use std::ops::Deref;
|
||||
use std::ops::DerefMut;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, Once};
|
||||
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::ProtocolObject;
|
||||
use objc2::{ClassType, MainThreadMarker, MainThreadOnly, define_class, msg_send};
|
||||
use objc2_app_kit::{NSApplication, NSApplicationDelegate, NSEvent, NSEventType, NSResponder};
|
||||
use objc2_foundation::{NSArray, NSObject, NSObjectProtocol, NSURL};
|
||||
|
||||
use crate::event::{AppEvent, AppEventScheduler};
|
||||
|
||||
static APP_EVENT_SCHEDULER: Mutex<Option<AppEventScheduler>> = Mutex::new(None);
|
||||
static PENDING_EVENTS: Mutex<Option<Vec<AppEvent>>> = Mutex::new(Some(Vec::new()));
|
||||
|
||||
fn dispatch_event(event: AppEvent) {
|
||||
let app_event_scheduler_guard = APP_EVENT_SCHEDULER.lock().unwrap();
|
||||
if let Some(app_event_scheduler) = app_event_scheduler_guard.deref() {
|
||||
app_event_scheduler.schedule(event);
|
||||
} else if let Some(pending_events) = PENDING_EVENTS.lock().unwrap().deref_mut() {
|
||||
pending_events.push(event);
|
||||
} else {
|
||||
tracing::error!("Failed to dispatch event");
|
||||
}
|
||||
}
|
||||
|
||||
fn instance() -> objc2::rc::Retained<NSApplication> {
|
||||
unsafe { msg_send![GraphiteApplication::class(), sharedApplication] }
|
||||
}
|
||||
|
||||
static INSTALL_DELEGATE: Once = Once::new();
|
||||
|
||||
pub(super) fn init() {
|
||||
let _ = instance();
|
||||
|
||||
INSTALL_DELEGATE.call_once(|| {
|
||||
let mtm = MainThreadMarker::new().expect("should only ever be called from main thread");
|
||||
let delegate: Retained<GraphiteApplicationDelegate> = unsafe { msg_send![super(GraphiteApplicationDelegate::alloc(mtm).set_ivars(())), init] };
|
||||
instance().setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
|
||||
std::mem::forget(delegate);
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn setup(app_event_scheduler: AppEventScheduler) {
|
||||
let mut app_event_scheduler_guard = APP_EVENT_SCHEDULER.lock().unwrap();
|
||||
|
||||
if let Some(mut pending_events) = PENDING_EVENTS.lock().unwrap().take() {
|
||||
pending_events.drain(..).for_each(|event| {
|
||||
app_event_scheduler.schedule(event);
|
||||
});
|
||||
} else {
|
||||
tracing::error!("Failed to take PENDING_EVENTS and schedule them. This a bug.");
|
||||
}
|
||||
|
||||
*app_event_scheduler_guard = Some(app_event_scheduler);
|
||||
}
|
||||
|
||||
pub(super) fn hide() {
|
||||
instance().hide(None);
|
||||
}
|
||||
|
||||
pub(super) fn hide_others() {
|
||||
instance().hideOtherApplications(None);
|
||||
}
|
||||
|
||||
pub(super) fn show_all() {
|
||||
instance().unhideAllApplications(None);
|
||||
}
|
||||
|
||||
define_class!(
|
||||
#[unsafe(super(NSApplication, NSResponder, NSObject))]
|
||||
#[name = "GraphiteApplication"]
|
||||
pub(super) struct GraphiteApplication;
|
||||
|
||||
impl GraphiteApplication {
|
||||
#[unsafe(method(sendEvent:))]
|
||||
fn send_event(&self, event: &NSEvent) {
|
||||
// Route keyDown events straight to the key window to skip native menu shortcut handling.
|
||||
if event.r#type() == NSEventType::KeyDown && let Some(key_window) = self.keyWindow() {
|
||||
unsafe { msg_send![&key_window, sendEvent: event] }
|
||||
} else {
|
||||
unsafe { msg_send![super(self), sendEvent: event] }
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
define_class!(
|
||||
#[unsafe(super(NSObject))]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[name = "GraphiteApplicationDelegate"]
|
||||
struct GraphiteApplicationDelegate;
|
||||
|
||||
unsafe impl NSObjectProtocol for GraphiteApplicationDelegate {}
|
||||
|
||||
unsafe impl NSApplicationDelegate for GraphiteApplicationDelegate {
|
||||
#[unsafe(method(application:openURLs:))]
|
||||
fn application_open_urls(&self, _application: &NSApplication, urls: &NSArray<NSURL>) {
|
||||
let paths = (0..urls.count())
|
||||
.filter_map(|index| {
|
||||
let url = urls.objectAtIndex(index);
|
||||
if !url.isFileURL() {
|
||||
tracing::error!("Ignoring open URL event for non-file URL: {:?}", url);
|
||||
return None;
|
||||
}
|
||||
let cstr = unsafe { CStr::from_ptr(url.fileSystemRepresentation().as_ptr()) };
|
||||
let path = PathBuf::from(OsStr::from_bytes(cstr.to_bytes()));
|
||||
Some(path)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
dispatch_event(AppEvent::OpenFiles(paths));
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,146 @@
|
||||
use muda::Menu as MudaMenu;
|
||||
use muda::accelerator::Accelerator;
|
||||
use muda::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, MenuItemKind, PredefinedMenuItem, Result, Submenu};
|
||||
|
||||
use crate::event::{AppEvent, AppEventScheduler};
|
||||
use crate::wrapper::messages::MenuItem as WrapperMenuItem;
|
||||
|
||||
pub(super) struct Menu {
|
||||
inner: MudaMenu,
|
||||
}
|
||||
|
||||
impl Menu {
|
||||
pub(super) fn new(event_scheduler: AppEventScheduler) -> Self {
|
||||
// TODO: Remove as much app submenu special handling as possible
|
||||
let app_submenu = Submenu::with_items("", true, &[]).unwrap();
|
||||
|
||||
let menu = MudaMenu::new();
|
||||
menu.prepend(&app_submenu).unwrap();
|
||||
|
||||
menu.init_for_nsapp();
|
||||
|
||||
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
|
||||
let mtm = objc2::MainThreadMarker::new().expect("only ever called from main thread");
|
||||
let is_shortcut_triggered = objc2_app_kit::NSApplication::sharedApplication(mtm)
|
||||
.mainMenu()
|
||||
.map(|m| m.highlightedItem().is_some())
|
||||
.unwrap_or_default();
|
||||
if is_shortcut_triggered {
|
||||
tracing::error!("A keyboard input triggered a menu event. This is most likely a bug. Please report!");
|
||||
return;
|
||||
}
|
||||
|
||||
let id = event.id().0.clone();
|
||||
event_scheduler.schedule(AppEvent::MenuEvent { id });
|
||||
}));
|
||||
|
||||
Menu { inner: menu }
|
||||
}
|
||||
|
||||
pub(super) fn update(&self, entries: Vec<WrapperMenuItem>) {
|
||||
let new_entries = menu_items_from_wrapper(entries);
|
||||
let existing_entries = self.inner.items();
|
||||
|
||||
let mut new_entries_iter = new_entries.iter();
|
||||
let mut existing_entries_iter = existing_entries.iter();
|
||||
|
||||
let incremental_update_ok = std::iter::from_fn(move || match (existing_entries_iter.next(), new_entries_iter.next()) {
|
||||
(Some(MenuItemKind::Submenu(old)), Some(MenuItemKind::Submenu(new))) if old.text() == new.text() => {
|
||||
replace_children(old, new.items());
|
||||
Some(true)
|
||||
}
|
||||
(None, None) => None,
|
||||
_ => Some(false),
|
||||
})
|
||||
.all(|b| b);
|
||||
|
||||
if !incremental_update_ok {
|
||||
// Fallback to full replace
|
||||
replace_children(&self.inner, new_entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn menu_items_from_wrapper(entries: Vec<WrapperMenuItem>) -> Vec<MenuItemKind> {
|
||||
let mut menu_items: Vec<MenuItemKind> = Vec::new();
|
||||
for entry in entries {
|
||||
match entry {
|
||||
WrapperMenuItem::Action { id, text, enabled, shortcut } => {
|
||||
let accelerator = shortcut.map(|s| Accelerator::new(Some(s.modifiers), s.key));
|
||||
let item = MenuItem::with_id(id, text, enabled, accelerator);
|
||||
menu_items.push(MenuItemKind::MenuItem(item));
|
||||
}
|
||||
WrapperMenuItem::Checkbox { id, text, enabled, shortcut, checked } => {
|
||||
let accelerator = shortcut.map(|s| Accelerator::new(Some(s.modifiers), s.key));
|
||||
let check = CheckMenuItem::with_id(id, text, enabled, checked, accelerator);
|
||||
menu_items.push(MenuItemKind::Check(check));
|
||||
}
|
||||
WrapperMenuItem::SubMenu { text: name, items, .. } => {
|
||||
let items = menu_items_from_wrapper(items);
|
||||
let items = items.iter().map(menu_item_kind_to_dyn).collect::<Vec<&dyn IsMenuItem>>();
|
||||
let submenu = Submenu::with_items(name, true, &items).unwrap();
|
||||
menu_items.push(MenuItemKind::Submenu(submenu));
|
||||
}
|
||||
WrapperMenuItem::Separator => {
|
||||
let separator = PredefinedMenuItem::separator();
|
||||
menu_items.push(MenuItemKind::Predefined(separator));
|
||||
}
|
||||
}
|
||||
}
|
||||
menu_items
|
||||
}
|
||||
|
||||
fn menu_item_kind_to_dyn(item: &MenuItemKind) -> &dyn IsMenuItem {
|
||||
match item {
|
||||
MenuItemKind::MenuItem(i) => i,
|
||||
MenuItemKind::Submenu(i) => i,
|
||||
MenuItemKind::Predefined(i) => i,
|
||||
MenuItemKind::Check(i) => i,
|
||||
MenuItemKind::Icon(i) => i,
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_children<'a, T: Into<MenuContainer<'a>>>(menu: T, new_items: Vec<MenuItemKind>) {
|
||||
let menu: MenuContainer = menu.into();
|
||||
let items = menu.items();
|
||||
for item in items.iter() {
|
||||
menu.remove(menu_item_kind_to_dyn(item)).unwrap();
|
||||
}
|
||||
let items = new_items.iter().map(menu_item_kind_to_dyn).collect::<Vec<&dyn IsMenuItem>>();
|
||||
menu.append_items(items.as_ref()).unwrap();
|
||||
}
|
||||
|
||||
enum MenuContainer<'a> {
|
||||
Menu(&'a MudaMenu),
|
||||
Submenu(&'a Submenu),
|
||||
}
|
||||
impl<'a> MenuContainer<'a> {
|
||||
fn items(&self) -> Vec<MenuItemKind> {
|
||||
match self {
|
||||
MenuContainer::Menu(menu) => menu.items(),
|
||||
MenuContainer::Submenu(submenu) => submenu.items(),
|
||||
}
|
||||
}
|
||||
fn remove(&self, item: &dyn IsMenuItem) -> Result<()> {
|
||||
match self {
|
||||
MenuContainer::Menu(menu) => menu.remove(item),
|
||||
MenuContainer::Submenu(submenu) => submenu.remove(item),
|
||||
}
|
||||
}
|
||||
fn append_items(&self, items: &[&dyn IsMenuItem]) -> Result<()> {
|
||||
match self {
|
||||
MenuContainer::Menu(menu) => menu.append_items(items),
|
||||
MenuContainer::Submenu(submenu) => submenu.append_items(items),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<'a> From<&'a MudaMenu> for MenuContainer<'a> {
|
||||
fn from(menu: &'a MudaMenu) -> Self {
|
||||
MenuContainer::Menu(menu)
|
||||
}
|
||||
}
|
||||
impl<'a> From<&'a Submenu> for MenuContainer<'a> {
|
||||
fn from(submenu: &'a Submenu) -> Self {
|
||||
MenuContainer::Submenu(submenu)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use windows::Win32::System::Com::{COINIT_APARTMENTTHREADED, CoInitializeEx};
|
||||
use windows::Win32::System::Console::{ATTACH_PARENT_PROCESS, AttachConsole};
|
||||
use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
||||
use windows::core::HSTRING;
|
||||
use winit::event_loop::ActiveEventLoop;
|
||||
use winit::window::{Window, WindowAttributes};
|
||||
|
||||
use crate::consts::APP_ID;
|
||||
use crate::event::AppEventScheduler;
|
||||
|
||||
pub(super) struct NativeWindowImpl {
|
||||
native_handle: native_handle::NativeWindowHandle,
|
||||
}
|
||||
|
||||
impl super::NativeWindow for NativeWindowImpl {
|
||||
fn init() {
|
||||
// Attach to parent console if launched from a terminal (no-op otherwise)
|
||||
unsafe {
|
||||
let _ = AttachConsole(ATTACH_PARENT_PROCESS);
|
||||
}
|
||||
|
||||
// Set stable app ID
|
||||
let app_id = HSTRING::from(APP_ID);
|
||||
unsafe {
|
||||
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED).ok();
|
||||
SetCurrentProcessExplicitAppUserModelID(&app_id).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn configure(attributes: WindowAttributes, _event_loop: &dyn ActiveEventLoop) -> WindowAttributes {
|
||||
attributes
|
||||
}
|
||||
|
||||
fn new(window: &dyn Window, _app_event_scheduler: AppEventScheduler) -> Self {
|
||||
let native_handle = native_handle::NativeWindowHandle::new(window);
|
||||
NativeWindowImpl { native_handle }
|
||||
}
|
||||
|
||||
fn can_render(&self) -> bool {
|
||||
self.native_handle.can_render()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NativeWindowImpl {
|
||||
fn drop(&mut self) {
|
||||
self.native_handle.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
mod native_handle;
|
||||
@@ -0,0 +1,410 @@
|
||||
//! Implements a Windows-specific custom window frame (no titlebar, but native boarder, shadows and resize).
|
||||
//! Look and feel should be similar to a standard window.
|
||||
//!
|
||||
//! Implementation notes:
|
||||
//! - Windows that don't use standard decorations don't get native resize handles or shadows by default.
|
||||
//! - We implement resize handles (outside the main window) by creating an invisible "helper" window that
|
||||
//! is a little larger than the main window and positioned on top of it. The helper window does hit-testing
|
||||
//! and triggers native resize operations on the main window when the user clicks and drags a resize area.
|
||||
//! - The helper window is a invisible window that never activates, so it doesn't steal focus from the main window.
|
||||
//! - The main window needs to update the helper window's position and size whenever it moves or resizes.
|
||||
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Instant;
|
||||
use wgpu::rwh::{HasWindowHandle, RawWindowHandle};
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::Graphics::Dwm::*;
|
||||
use windows::Win32::Graphics::Gdi::*;
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::Controls::MARGINS;
|
||||
use windows::Win32::UI::HiDpi::*;
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
use windows::core::PCWSTR;
|
||||
use winit::window::Window;
|
||||
|
||||
#[derive(Default)]
|
||||
struct NativeWindowState {
|
||||
can_render: bool,
|
||||
can_render_since: Option<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct NativeWindowHandle {
|
||||
main: HWND,
|
||||
helper: HWND,
|
||||
prev_window_message_handler: isize,
|
||||
state: Arc<Mutex<NativeWindowState>>,
|
||||
}
|
||||
impl NativeWindowHandle {
|
||||
pub(super) fn new(window: &dyn Window) -> NativeWindowHandle {
|
||||
// Extract Win32 HWND from winit.
|
||||
let main = match window.window_handle().expect("No window handle").as_raw() {
|
||||
RawWindowHandle::Win32(h) => HWND(h.hwnd.get() as *mut std::ffi::c_void),
|
||||
_ => panic!("Not a Win32 window"),
|
||||
};
|
||||
|
||||
// Register the invisible helper (resize ring) window class.
|
||||
unsafe { ensure_helper_class() };
|
||||
|
||||
// Create the helper as a popup tool window that never activates.
|
||||
// WS_EX_NOACTIVATE keeps focus on the main window; WS_EX_TOOLWINDOW hides it from Alt+Tab.
|
||||
// https://learn.microsoft.com/windows/win32/winmsg/extended-window-styles
|
||||
let ex = WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW;
|
||||
let style = WS_POPUP;
|
||||
let helper = unsafe {
|
||||
CreateWindowExW(
|
||||
ex,
|
||||
PCWSTR(HELPER_CLASS_NAME.encode_utf16().collect::<Vec<_>>().as_ptr()),
|
||||
PCWSTR::null(),
|
||||
style,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Some(main),
|
||||
None,
|
||||
None,
|
||||
// Pass the main window's HWND to WM_NCCREATE so the helper can store it.
|
||||
Some(&main as *const _ as _),
|
||||
)
|
||||
}
|
||||
.expect("CreateWindowExW failed");
|
||||
|
||||
// Subclass the main window.
|
||||
// https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-setwindowlongptra
|
||||
let prev_window_message_handler = unsafe { SetWindowLongPtrW(main, GWLP_WNDPROC, main_window_handle_message as *const () as isize) };
|
||||
if prev_window_message_handler == 0 {
|
||||
let _ = unsafe { DestroyWindow(helper) };
|
||||
panic!("SetWindowLongPtrW failed");
|
||||
}
|
||||
|
||||
let native_handle = NativeWindowHandle {
|
||||
main,
|
||||
helper,
|
||||
prev_window_message_handler,
|
||||
state: Arc::new(Mutex::new(NativeWindowState::default())),
|
||||
};
|
||||
registry::insert(&native_handle);
|
||||
|
||||
// Place the helper over the main window and show it without activation.
|
||||
unsafe { position_helper(main, helper) };
|
||||
let _ = unsafe { ShowWindow(helper, SW_SHOWNOACTIVATE) };
|
||||
|
||||
// DwmExtendFrameIntoClientArea is needed to keep native window frame (but no titlebar).
|
||||
// https://learn.microsoft.com/windows/win32/api/dwmapi/nf-dwmapi-dwmextendframeintoclientarea
|
||||
// https://learn.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
|
||||
let mut boarder_size: u32 = 1;
|
||||
let _ = unsafe { DwmGetWindowAttribute(main, DWMWA_VISIBLE_FRAME_BORDER_THICKNESS, &mut boarder_size as *mut _ as *mut _, size_of::<u32>() as u32) };
|
||||
let margins = MARGINS {
|
||||
cxLeftWidth: 0,
|
||||
cxRightWidth: 0,
|
||||
cyBottomHeight: 0,
|
||||
cyTopHeight: boarder_size as i32,
|
||||
};
|
||||
let _ = unsafe { DwmExtendFrameIntoClientArea(main, &margins) };
|
||||
|
||||
let hinst: HINSTANCE = unsafe { GetModuleHandleW(None) }.unwrap().into();
|
||||
|
||||
// Set taskbar icon
|
||||
if let Ok(big) = unsafe {
|
||||
LoadImageW(
|
||||
Some(hinst),
|
||||
PCWSTR(1usize as *const u16),
|
||||
IMAGE_ICON,
|
||||
GetSystemMetrics(SM_CXICON),
|
||||
GetSystemMetrics(SM_CYICON),
|
||||
LR_SHARED,
|
||||
)
|
||||
} {
|
||||
unsafe { SetClassLongPtrW(main, GCLP_HICON, big.0 as isize) };
|
||||
unsafe { SendMessageW(main, WM_SETICON, Some(WPARAM(ICON_BIG as usize)), Some(LPARAM(big.0 as isize))) };
|
||||
}
|
||||
|
||||
// Set window icon
|
||||
if let Ok(small) = unsafe {
|
||||
LoadImageW(
|
||||
Some(hinst),
|
||||
PCWSTR(1usize as *const u16),
|
||||
IMAGE_ICON,
|
||||
GetSystemMetrics(SM_CXSMICON),
|
||||
GetSystemMetrics(SM_CYSMICON),
|
||||
LR_SHARED,
|
||||
)
|
||||
} {
|
||||
unsafe { SetClassLongPtrW(main, GCLP_HICONSM, small.0 as isize) };
|
||||
unsafe { SendMessageW(main, WM_SETICON, Some(WPARAM(ICON_SMALL as usize)), Some(LPARAM(small.0 as isize))) };
|
||||
}
|
||||
|
||||
// Force window update
|
||||
let _ = unsafe { SetWindowPos(main, None, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE) };
|
||||
|
||||
native_handle
|
||||
}
|
||||
|
||||
pub(super) fn destroy(&self) {
|
||||
registry::remove_by_main(self.main);
|
||||
|
||||
// Undo subclassing and destroy the helper window.
|
||||
let _ = unsafe { SetWindowLongPtrW(self.main, GWLP_WNDPROC, self.prev_window_message_handler) };
|
||||
if !self.helper.is_invalid() {
|
||||
let _ = unsafe { DestroyWindow(self.helper) };
|
||||
}
|
||||
}
|
||||
|
||||
// Rendering should be disabled when window is minimized
|
||||
// Rendering also needs to be disabled during minimize and restore animations
|
||||
// Reenabling rendering is done after a small delay to account for restore animation
|
||||
// TODO: Find a cleaner solution that doesn't depend on a timeout
|
||||
pub(super) fn can_render(&self) -> bool {
|
||||
let can_render = !unsafe { IsIconic(self.main).into() } && unsafe { IsWindowVisible(self.main).into() };
|
||||
let Ok(mut state) = self.state.lock() else {
|
||||
tracing::error!("Failed to lock NativeWindowState");
|
||||
return true;
|
||||
};
|
||||
match (can_render, state.can_render, state.can_render_since) {
|
||||
(true, false, None) => {
|
||||
state.can_render_since = Some(Instant::now());
|
||||
}
|
||||
(true, false, Some(can_render_since)) if can_render_since.elapsed().as_millis() > 50 => {
|
||||
state.can_render = true;
|
||||
state.can_render_since = None;
|
||||
}
|
||||
(false, true, _) => {
|
||||
state.can_render = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
state.can_render
|
||||
}
|
||||
}
|
||||
|
||||
mod registry {
|
||||
use std::cell::RefCell;
|
||||
use windows::Win32::Foundation::HWND;
|
||||
|
||||
use super::NativeWindowHandle;
|
||||
|
||||
thread_local! {
|
||||
static STORE: RefCell<Vec<NativeWindowHandle>> = RefCell::new(Vec::new());
|
||||
}
|
||||
|
||||
pub(super) fn find_by_main(main: HWND) -> Option<NativeWindowHandle> {
|
||||
STORE.with_borrow(|vec| vec.iter().find(|h| h.main == main).cloned())
|
||||
}
|
||||
pub(super) fn remove_by_main(main: HWND) {
|
||||
STORE.with_borrow_mut(|vec| {
|
||||
vec.retain(|h| h.main != main);
|
||||
});
|
||||
}
|
||||
pub(super) fn insert(handle: &NativeWindowHandle) {
|
||||
STORE.with_borrow_mut(|vec| {
|
||||
vec.push(handle.clone());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const HELPER_CLASS_NAME: &str = "Helper\0";
|
||||
|
||||
static HELPER_CLASS_LOCK: OnceLock<u16> = OnceLock::new();
|
||||
unsafe fn ensure_helper_class() {
|
||||
// Register a window class for the invisible resize helper.
|
||||
let _ = *HELPER_CLASS_LOCK.get_or_init(|| {
|
||||
let class_name: Vec<u16> = HELPER_CLASS_NAME.encode_utf16().collect();
|
||||
let wc = WNDCLASSW {
|
||||
style: CS_HREDRAW | CS_VREDRAW,
|
||||
lpfnWndProc: Some(helper_window_handle_message),
|
||||
hInstance: unsafe { GetModuleHandleW(None).unwrap().into() },
|
||||
hIcon: HICON::default(),
|
||||
hCursor: unsafe { LoadCursorW(None, IDC_ARROW).unwrap() },
|
||||
// No painting; the ring is invisible.
|
||||
hbrBackground: HBRUSH::default(),
|
||||
lpszClassName: PCWSTR(class_name.as_ptr()),
|
||||
..Default::default()
|
||||
};
|
||||
unsafe { RegisterClassW(&wc) }
|
||||
});
|
||||
}
|
||||
|
||||
// Main window message handler, called on the UI thread for every message the main window receives.
|
||||
unsafe extern "system" fn main_window_handle_message(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
if msg == WM_NCCALCSIZE && wparam.0 != 0 {
|
||||
let params = unsafe { &mut *(lparam.0 as *mut NCCALCSIZE_PARAMS) };
|
||||
|
||||
// When maximized, shrink to visible frame so content doesn't extend beyond it.
|
||||
if unsafe { IsZoomed(hwnd).as_bool() } && !is_effectively_fullscreen(params.rgrc[0]) {
|
||||
let dpi = unsafe { GetDpiForWindow(hwnd) };
|
||||
let size = unsafe { GetSystemMetricsForDpi(SM_CXSIZEFRAME, dpi) };
|
||||
let pad = unsafe { GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi) };
|
||||
let inset = (size + pad) as i32;
|
||||
|
||||
params.rgrc[0].left += inset;
|
||||
params.rgrc[0].top += inset;
|
||||
params.rgrc[0].right -= inset;
|
||||
params.rgrc[0].bottom -= inset;
|
||||
}
|
||||
|
||||
// Return 0 to to tell Windows to skip the default non-client area calculation and drawing.
|
||||
return LRESULT(0);
|
||||
}
|
||||
|
||||
let Some(handle) = registry::find_by_main(hwnd) else {
|
||||
return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) };
|
||||
};
|
||||
|
||||
match msg {
|
||||
// Keep the invisible resize helper in sync with moves/resizes/visibility.
|
||||
WM_MOVE | WM_MOVING | WM_SIZE | WM_SIZING | WM_WINDOWPOSCHANGED | WM_SHOWWINDOW => {
|
||||
if msg == WM_SHOWWINDOW {
|
||||
if wparam.0 == 0 {
|
||||
let _ = unsafe { ShowWindow(handle.helper, SW_HIDE) };
|
||||
} else {
|
||||
let _ = unsafe { ShowWindow(handle.helper, SW_SHOWNOACTIVATE) };
|
||||
}
|
||||
}
|
||||
unsafe { position_helper(hwnd, handle.helper) };
|
||||
}
|
||||
|
||||
// If the main window is destroyed, destroy the helper too.
|
||||
// Should only be needed if windows forcefully destroys the main window.
|
||||
WM_DESTROY => {
|
||||
let _ = unsafe { DestroyWindow(handle.helper) };
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Ensure the previous window message handler is not null.
|
||||
assert_ne!(handle.prev_window_message_handler, 0);
|
||||
|
||||
// Call the previous window message handler, this is a standard subclassing pattern.
|
||||
let prev_window_message_handler_fn_ptr: *const () = std::ptr::without_provenance(handle.prev_window_message_handler as usize);
|
||||
let prev_window_message_handler_fn = unsafe { std::mem::transmute::<_, _>(prev_window_message_handler_fn_ptr) };
|
||||
unsafe { CallWindowProcW(Some(prev_window_message_handler_fn), hwnd, msg, wparam, lparam) }
|
||||
}
|
||||
|
||||
// Helper window message handler, called on the UI thread for every message the helper window receives.
|
||||
unsafe extern "system" fn helper_window_handle_message(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
match msg {
|
||||
// Helper window creation, should be the first message that the helper window receives.
|
||||
WM_NCCREATE => {
|
||||
// Main window HWND is provided when creating the helper window with `CreateWindowExW`
|
||||
// Save main window HWND in GWLP_USERDATA so we can extract it later
|
||||
let crate_struct = lparam.0 as *const CREATESTRUCTW;
|
||||
let create_param = unsafe { (*crate_struct).lpCreateParams as *const HWND };
|
||||
unsafe { SetWindowLongPtrW(hwnd, GWLP_USERDATA, (*create_param).0 as isize) };
|
||||
return LRESULT(1);
|
||||
}
|
||||
|
||||
// Invisible; no background erase.
|
||||
WM_ERASEBKGND => return LRESULT(1),
|
||||
|
||||
// Tell windows what resize areas we are hitting, this is used to decide what cursor to show.
|
||||
WM_NCHITTEST => {
|
||||
let ht = unsafe { calculate_hit(hwnd, lparam) };
|
||||
return LRESULT(ht as isize);
|
||||
}
|
||||
|
||||
// This starts the system's resize loop for the main window if a resize area is hit.
|
||||
// Helper window button down translates to SC_SIZE | WMSZ_* on the main window.
|
||||
WM_NCLBUTTONDOWN | WM_NCRBUTTONDOWN | WM_NCMBUTTONDOWN => {
|
||||
// Extract the main window's HWND from GWLP_USERDATA that we saved earlier.
|
||||
let main_ptr = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as *mut std::ffi::c_void;
|
||||
let main = HWND(main_ptr);
|
||||
if unsafe { IsWindow(Some(main)).as_bool() } {
|
||||
let Some(wmsz) = (unsafe { calculate_resize_direction(hwnd, lparam) }) else {
|
||||
return LRESULT(0);
|
||||
};
|
||||
|
||||
// Ensure that the main window can receive WM_SYSCOMMAND.
|
||||
let _ = unsafe { SetForegroundWindow(main) };
|
||||
|
||||
// Start sizing on the main window in the calculated direction. (SC_SIZE + WMSZ_*)
|
||||
let _ = unsafe { PostMessageW(Some(main), WM_SYSCOMMAND, WPARAM((SC_SIZE + wmsz) as usize), lparam) };
|
||||
}
|
||||
return LRESULT(0);
|
||||
}
|
||||
|
||||
// Never activate the helper window, allows all inputs that don't hit the resize areas to pass through.
|
||||
WM_MOUSEACTIVATE => return LRESULT(MA_NOACTIVATE as isize),
|
||||
_ => {}
|
||||
}
|
||||
unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }
|
||||
}
|
||||
|
||||
const RESIZE_BAND_THICKNESS: i32 = 8;
|
||||
|
||||
// Position the helper window to match the main window's location and size (plus the resize band size).
|
||||
unsafe fn position_helper(main: HWND, helper: HWND) {
|
||||
let mut r = RECT::default();
|
||||
let _ = unsafe { GetWindowRect(main, &mut r) };
|
||||
|
||||
let x = r.left - RESIZE_BAND_THICKNESS;
|
||||
let y = r.top - RESIZE_BAND_THICKNESS;
|
||||
let w = (r.right - r.left) + RESIZE_BAND_THICKNESS * 2;
|
||||
let h = (r.bottom - r.top) + RESIZE_BAND_THICKNESS * 2;
|
||||
|
||||
let _ = unsafe { SetWindowPos(helper, Some(main), x, y, w, h, SWP_NOACTIVATE | SWP_NOSENDCHANGING) };
|
||||
}
|
||||
|
||||
unsafe fn calculate_hit(helper: HWND, lparam: LPARAM) -> u32 {
|
||||
let x = (lparam.0 & 0xFFFF) as i16 as i32;
|
||||
let y = ((lparam.0 >> 16) & 0xFFFF) as i16 as i32;
|
||||
|
||||
let mut r = RECT::default();
|
||||
let _ = unsafe { GetWindowRect(helper, &mut r) };
|
||||
|
||||
let on_top = y < (r.top + RESIZE_BAND_THICKNESS) as i32;
|
||||
let on_right = x >= (r.right - RESIZE_BAND_THICKNESS) as i32;
|
||||
let on_bottom = y >= (r.bottom - RESIZE_BAND_THICKNESS) as i32;
|
||||
let on_left = x < (r.left + RESIZE_BAND_THICKNESS) as i32;
|
||||
|
||||
match (on_top, on_right, on_bottom, on_left) {
|
||||
(true, _, _, true) => HTTOPLEFT,
|
||||
(true, true, _, _) => HTTOPRIGHT,
|
||||
(_, true, true, _) => HTBOTTOMRIGHT,
|
||||
(_, _, true, true) => HTBOTTOMLEFT,
|
||||
(true, _, _, _) => HTTOP,
|
||||
(_, true, _, _) => HTRIGHT,
|
||||
(_, _, true, _) => HTBOTTOM,
|
||||
(_, _, _, true) => HTLEFT,
|
||||
_ => HTTRANSPARENT as u32,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn calculate_resize_direction(helper: HWND, lparam: LPARAM) -> Option<u32> {
|
||||
match unsafe { calculate_hit(helper, lparam) } {
|
||||
HTLEFT => Some(WMSZ_LEFT),
|
||||
HTRIGHT => Some(WMSZ_RIGHT),
|
||||
HTTOP => Some(WMSZ_TOP),
|
||||
HTBOTTOM => Some(WMSZ_BOTTOM),
|
||||
HTTOPLEFT => Some(WMSZ_TOPLEFT),
|
||||
HTTOPRIGHT => Some(WMSZ_TOPRIGHT),
|
||||
HTBOTTOMLEFT => Some(WMSZ_BOTTOMLEFT),
|
||||
HTBOTTOMRIGHT => Some(WMSZ_BOTTOMRIGHT),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the rect is effectively fullscreen, meaning it would cover the entire monitor.
|
||||
// We need to use this heuristic because Windows doesn't provide a way to check for fullscreen state.
|
||||
fn is_effectively_fullscreen(rect: RECT) -> bool {
|
||||
let hmon = unsafe { MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST) };
|
||||
if hmon.is_invalid() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut monitor_info = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
if !unsafe { GetMonitorInfoW(hmon, &mut monitor_info) }.as_bool() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow a tiny tolerance for DPI / rounding issues
|
||||
const EPS: i32 = 1;
|
||||
(rect.left - monitor_info.rcMonitor.left).abs() <= EPS
|
||||
&& (rect.top - monitor_info.rcMonitor.top).abs() <= EPS
|
||||
&& (rect.right - monitor_info.rcMonitor.right).abs() <= EPS
|
||||
&& (rect.bottom - monitor_info.rcMonitor.bottom).abs() <= EPS
|
||||
}
|
||||
Reference in New Issue
Block a user