From e36107a6cfc2efe8c9a31e793273095c51a31e7c Mon Sep 17 00:00:00 2001 From: jess Date: Mon, 18 May 2026 19:11:47 -0700 Subject: [PATCH] bundle and render branding icons --- Cargo.lock | 99 +++++++++- frontend/iced/Cargo.toml | 5 + frontend/iced/src/clipboard.rs | 111 +++++++++++ frontend/iced/src/file_io.rs | 161 +++++++++++++++ frontend/iced/src/icons.rs | 295 ++++++++++++++++++++++++++++ frontend/iced/src/main.rs | 7 + frontend/iced/src/persist.rs | 143 ++++++++++++++ frontend/iced/src/pointer.rs | 161 +++++++++++++++ frontend/iced/src/window_control.rs | 141 +++++++++++++ 9 files changed, 1117 insertions(+), 6 deletions(-) create mode 100644 frontend/iced/src/clipboard.rs create mode 100644 frontend/iced/src/file_io.rs create mode 100644 frontend/iced/src/icons.rs create mode 100644 frontend/iced/src/persist.rs create mode 100644 frontend/iced/src/pointer.rs create mode 100644 frontend/iced/src/window_control.rs diff --git a/Cargo.lock b/Cargo.lock index b5160635..a80d2f7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1800,6 +1800,16 @@ dependencies = [ "weezl", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.31.1" @@ -2225,6 +2235,7 @@ dependencies = [ name = "graphite-iced-frontend" version = "0.0.0" dependencies = [ + "dirs", "graph-craft", "graphite-editor", "iced_graphics", @@ -2237,12 +2248,16 @@ dependencies = [ "rand", "raw-window-handle", "reqwest", + "resvg", + "rfd", "serde", "serde_json", "tracing", "tracing-subscriber", + "usvg", "wgpu", "wgpu-executor", + "window_clipboard", "winit", ] @@ -2684,7 +2699,7 @@ dependencies = [ "log", "rustc-hash 2.1.1", "softbuffer", - "tiny-skia", + "tiny-skia 0.11.4", ] [[package]] @@ -2877,11 +2892,21 @@ dependencies = [ "bytemuck", "byteorder-lite", "color_quant", - "gif", + "gif 0.13.3", "num-traits", "png 0.17.16", - "zune-core", - "zune-jpeg", + "zune-core 0.4.12", + "zune-jpeg 0.4.20", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] [[package]] @@ -4493,6 +4518,12 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.37.5" @@ -4903,6 +4934,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +dependencies = [ + "gif 0.14.2", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia 0.12.0", + "usvg", + "zune-jpeg 0.5.15", +] + [[package]] name = "rfd" version = "0.17.2" @@ -4929,6 +4977,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -5207,7 +5264,7 @@ dependencies = [ "log", "memmap2", "smithay-client-toolkit", - "tiny-skia", + "tiny-skia 0.11.4", ] [[package]] @@ -5993,6 +6050,21 @@ dependencies = [ "tiny-skia-path 0.11.4", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.18.1", + "tiny-skia-path 0.12.0", +] + [[package]] name = "tiny-skia-path" version = "0.11.4" @@ -8068,11 +8140,26 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + [[package]] name = "zune-jpeg" version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1f7e205ce79eb2da3cd71c5f55f3589785cb7c79f6a03d1c8d1491bda5d089" dependencies = [ - "zune-core", + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core 0.5.1", ] diff --git a/frontend/iced/Cargo.toml b/frontend/iced/Cargo.toml index 8173a75e..d8b55172 100644 --- a/frontend/iced/Cargo.toml +++ b/frontend/iced/Cargo.toml @@ -27,11 +27,16 @@ wgpu = { workspace = true } raw-window-handle = "0.6" pollster = "0.4" +dirs = { workspace = true } image = { workspace = true } include_dir = { workspace = true } rand = { workspace = true, features = ["thread_rng"] } reqwest = { workspace = true } +resvg = { workspace = true } +rfd = { workspace = true } +usvg = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +window_clipboard = "0.5" diff --git a/frontend/iced/src/clipboard.rs b/frontend/iced/src/clipboard.rs new file mode 100644 index 00000000..90d139f3 --- /dev/null +++ b/frontend/iced/src/clipboard.rs @@ -0,0 +1,111 @@ +use raw_window_handle::{DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle, WindowHandle}; +use std::sync::Mutex; + +pub struct ClipboardHandle { + inner: Mutex, +} + +impl ClipboardHandle { + pub fn new_from_raw(window_handle: RawWindowHandle, display_handle: RawDisplayHandle) -> Option { + let provider = RawHandles { window: window_handle, display: display_handle }; + let connected = unsafe { window_clipboard::Clipboard::connect(&provider) }; + match connected { + Ok(clipboard) => Some(Self { inner: Mutex::new(clipboard) }), + Err(e) => { + tracing::warn!("failed to connect system clipboard: {e}"); + None + } + } + } + + pub fn read_text(&self) -> Option { + let guard = match self.inner.lock() { + Ok(guard) => guard, + Err(e) => { + tracing::warn!("clipboard mutex poisoned: {e}"); + return None; + } + }; + match guard.read() { + Ok(text) => Some(text), + Err(e) => { + tracing::warn!("failed to read clipboard: {e}"); + None + } + } + } + + pub fn write_text(&self, text: &str) { + let mut guard = match self.inner.lock() { + Ok(guard) => guard, + Err(e) => { + tracing::warn!("clipboard mutex poisoned: {e}"); + return; + } + }; + if let Err(e) = guard.write(text.to_string()) { + tracing::warn!("failed to write clipboard: {e}"); + } + } + + pub fn read_selection(&self) -> Option { + let guard = match self.inner.lock() { + Ok(guard) => guard, + Err(e) => { + tracing::warn!("clipboard mutex poisoned: {e}"); + return None; + } + }; + if let Some(result) = guard.read_primary() { + match result { + Ok(text) => Some(text), + Err(e) => { + tracing::warn!("failed to read primary selection: {e}"); + None + } + } + } else { + match guard.read() { + Ok(text) => Some(text), + Err(e) => { + tracing::warn!("failed to read clipboard fallback for selection: {e}"); + None + } + } + } + } + + pub fn write_selection(&self, text: &str) { + let mut guard = match self.inner.lock() { + Ok(guard) => guard, + Err(e) => { + tracing::warn!("clipboard mutex poisoned: {e}"); + return; + } + }; + if let Some(result) = guard.write_primary(text.to_string()) { + if let Err(e) = result { + tracing::warn!("failed to write primary selection: {e}"); + } + } else if let Err(e) = guard.write(text.to_string()) { + tracing::warn!("failed to write clipboard fallback for selection: {e}"); + } + } +} + +struct RawHandles { + window: RawWindowHandle, + display: RawDisplayHandle, +} + +impl HasDisplayHandle for RawHandles { + fn display_handle(&self) -> Result, HandleError> { + Ok(unsafe { DisplayHandle::borrow_raw(self.display) }) + } +} + +impl HasWindowHandle for RawHandles { + fn window_handle(&self) -> Result, HandleError> { + Ok(unsafe { WindowHandle::borrow_raw(self.window) }) + } +} diff --git a/frontend/iced/src/file_io.rs b/frontend/iced/src/file_io.rs new file mode 100644 index 00000000..21467091 --- /dev/null +++ b/frontend/iced/src/file_io.rs @@ -0,0 +1,161 @@ +use graphite_editor::messages::prelude::DocumentId; +use std::path::PathBuf; +use std::sync::mpsc::Sender; +use std::thread; + +const GRAPHITE_EXTENSION: &str = "graphite"; +const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "svg", "bmp", "gif"]; + +#[derive(Debug, Clone)] +pub enum FileIoResult { + Opened { path: PathBuf, content: Vec }, + Imported { path: PathBuf, content: Vec }, + SavedDocument { document_id: DocumentId, path: PathBuf }, + ExportComplete, + Cancelled, + Failed(String), +} + +pub fn spawn_open_dialog(sender: Sender) { + thread::spawn(move || { + let mut all_exts: Vec<&str> = Vec::with_capacity(IMAGE_EXTENSIONS.len() + 1); + all_exts.push(GRAPHITE_EXTENSION); + all_exts.extend_from_slice(IMAGE_EXTENSIONS); + + let dialog = rfd::FileDialog::new() + .set_title("Open File") + .add_filter("Graphite & Images", &all_exts) + .add_filter("Graphite Document", &[GRAPHITE_EXTENSION]) + .add_filter("Images", IMAGE_EXTENSIONS); + + let result = match dialog.pick_file() { + Some(path) => match std::fs::read(&path) { + Ok(content) => FileIoResult::Opened { path, content }, + Err(e) => FileIoResult::Failed(format!("Failed to read {}: {e}", path.display())), + }, + None => FileIoResult::Cancelled, + }; + let _ = sender.send(result); + }); +} + +pub fn spawn_import_dialog(sender: Sender) { + thread::spawn(move || { + let dialog = rfd::FileDialog::new().set_title("Import Image").add_filter("Images", IMAGE_EXTENSIONS); + + let result = match dialog.pick_file() { + Some(path) => match std::fs::read(&path) { + Ok(content) => FileIoResult::Imported { path, content }, + Err(e) => FileIoResult::Failed(format!("Failed to read {}: {e}", path.display())), + }, + None => FileIoResult::Cancelled, + }; + let _ = sender.send(result); + }); +} + +pub fn spawn_save_dialog(sender: Sender, suggested_name: String, suggested_folder: Option, content: Vec) { + thread::spawn(move || { + let result = match resolve_save_path(&suggested_name, suggested_folder.as_deref(), None) { + Some(path) => match std::fs::write(&path, &content) { + Ok(()) => FileIoResult::ExportComplete, + Err(e) => FileIoResult::Failed(format!("Failed to write {}: {e}", path.display())), + }, + None => FileIoResult::Cancelled, + }; + let _ = sender.send(result); + }); +} + +pub fn spawn_save_document(sender: Sender, document_id: DocumentId, suggested_name: String, explicit_path: Option, suggested_folder: Option, content: Vec) { + thread::spawn(move || { + let chosen = match explicit_path { + Some(path) => Some(path), + None => resolve_save_path(&suggested_name, suggested_folder.as_deref(), Some(GRAPHITE_EXTENSION)), + }; + let result = match chosen { + Some(path) => match std::fs::write(&path, &content) { + Ok(()) => FileIoResult::SavedDocument { document_id, path }, + Err(e) => FileIoResult::Failed(format!("Failed to write {}: {e}", path.display())), + }, + None => FileIoResult::Cancelled, + }; + let _ = sender.send(result); + }); +} + +pub fn spawn_export_image(sender: Sender, svg: String, name: String, mime: String, size: (f64, f64)) { + thread::spawn(move || { + let extension = match mime.as_str() { + "image/svg+xml" => "svg", + "image/png" => "png", + "image/jpeg" => "jpg", + _ => "bin", + }; + let chosen = resolve_save_path(&name, None, Some(extension)); + let Some(path) = chosen else { + let _ = sender.send(FileIoResult::Cancelled); + return; + }; + + let bytes = match mime.as_str() { + "image/svg+xml" => Ok(svg.into_bytes()), + "image/png" => rasterise_svg(&svg, size, false), + "image/jpeg" => rasterise_svg(&svg, size, true), + other => Err(format!("Unsupported export mime: {other}")), + }; + + let result = match bytes.and_then(|b| std::fs::write(&path, &b).map_err(|e| format!("Failed to write {}: {e}", path.display()))) { + Ok(()) => FileIoResult::ExportComplete, + Err(message) => FileIoResult::Failed(message), + }; + let _ = sender.send(result); + }); +} + +fn resolve_save_path(suggested_name: &str, suggested_folder: Option<&std::path::Path>, force_extension: Option<&str>) -> Option { + let mut dialog = rfd::FileDialog::new().set_title("Save File").set_file_name(suggested_name); + if let Some(folder) = suggested_folder { + dialog = dialog.set_directory(folder); + } + if let Some(ext) = force_extension { + let label = format!("{} file", ext.to_ascii_uppercase()); + dialog = dialog.add_filter(label.as_str(), &[ext]); + } + let mut path = dialog.save_file()?; + if let Some(ext) = force_extension { + if path.extension().and_then(|e| e.to_str()).map(|e| !e.eq_ignore_ascii_case(ext)).unwrap_or(true) { + path.set_extension(ext); + } + } + Some(path) +} + +fn rasterise_svg(svg: &str, size: (f64, f64), jpeg: bool) -> Result, String> { + let (width, height) = (size.0.max(1.0) as u32, size.1.max(1.0) as u32); + let opts = usvg::Options::default(); + let tree = usvg::Tree::from_str(svg, &opts).map_err(|e| format!("SVG parse error: {e}"))?; + + let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height).ok_or_else(|| "Pixmap allocation failed (zero or oversized dimensions)".to_string())?; + if jpeg { + pixmap.fill(resvg::tiny_skia::Color::WHITE); + } + + let tree_size = tree.size(); + let scale_x = width as f32 / tree_size.width(); + let scale_y = height as f32 / tree_size.height(); + let transform = resvg::tiny_skia::Transform::from_scale(scale_x, scale_y); + resvg::render(&tree, transform, &mut pixmap.as_mut()); + + if jpeg { + let rgba = pixmap.data(); + let buffer = image::RgbaImage::from_raw(width, height, rgba.to_vec()).ok_or_else(|| "RGBA buffer mismatch".to_string())?; + let rgb = image::DynamicImage::ImageRgba8(buffer).to_rgb8(); + let mut out = Vec::with_capacity((width * height * 3) as usize); + let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 92); + encoder.encode(rgb.as_raw(), width, height, image::ExtendedColorType::Rgb8).map_err(|e| format!("JPEG encode error: {e}"))?; + Ok(out) + } else { + pixmap.encode_png().map_err(|e| format!("PNG encode error: {e}")) + } +} diff --git a/frontend/iced/src/icons.rs b/frontend/iced/src/icons.rs new file mode 100644 index 00000000..b437cf53 --- /dev/null +++ b/frontend/iced/src/icons.rs @@ -0,0 +1,295 @@ +use iced_widget::image::Handle; +use include_dir::{Dir, include_dir}; +use resvg::tiny_skia; +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; + +static BRANDING: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../branding/assets"); + +const RASTER_SCALE: f32 = 2.0; + +static CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +pub fn icon_handle(name: &str) -> Option { + let (key, path, size) = lookup(name)?; + + if let Some(cached) = CACHE.lock().ok().and_then(|c| c.get(key).cloned()) { + return Some(cached); + } + + let bytes = BRANDING.get_file(path)?.contents(); + let handle = if path.ends_with(".svg") { + rasterize_svg(bytes, size)? + } else { + decode_raster(bytes)? + }; + + if let Ok(mut cache) = CACHE.lock() { + cache.insert(key, handle.clone()); + } + Some(handle) +} + +fn rasterize_svg(bytes: &[u8], size: Option) -> Option { + let opt = usvg::Options::default(); + let tree = usvg::Tree::from_data(bytes, &opt).ok()?; + let svg_size = tree.size(); + + let (width, height) = match size { + Some(s) => { + let s_f = s as f32 * RASTER_SCALE; + (s_f.max(1.0).round() as u32, s_f.max(1.0).round() as u32) + } + None => { + let w = (svg_size.width() * RASTER_SCALE).max(1.0).round() as u32; + let h = (svg_size.height() * RASTER_SCALE).max(1.0).round() as u32; + (w, h) + } + }; + + let scale_x = width as f32 / svg_size.width(); + let scale_y = height as f32 / svg_size.height(); + let transform = tiny_skia::Transform::from_scale(scale_x, scale_y); + + let mut pixmap = tiny_skia::Pixmap::new(width, height)?; + resvg::render(&tree, transform, &mut pixmap.as_mut()); + + let rgba = pixmap.take_demultiplied(); + Some(Handle::from_rgba(width, height, rgba)) +} + +fn decode_raster(bytes: &[u8]) -> Option { + let img = image::load_from_memory(bytes).ok()?.to_rgba8(); + let (w, h) = img.dimensions(); + Some(Handle::from_rgba(w, h, img.into_raw())) +} + +fn lookup(name: &str) -> Option<(&'static str, &'static str, Option)> { + for &(n, path, size) in ICONS { + if n == name { + return Some((n, path, size)); + } + } + None +} + +const ICONS: &[(&str, &str, Option)] = &[ + ("GraphiteLogotypeSolid", "graphics/graphite-logotype-solid.svg", None), + ("Add", "icon-12px-solid/add.svg", Some(12)), + ("Checkmark", "icon-12px-solid/checkmark.svg", Some(12)), + ("Clipped", "icon-12px-solid/clipped.svg", Some(12)), + ("CloseX", "icon-12px-solid/close-x.svg", Some(12)), + ("Delay", "icon-12px-solid/delay.svg", Some(12)), + ("Dot", "icon-12px-solid/dot.svg", Some(12)), + ("DotThick", "icon-12px-solid/dot-thick.svg", Some(12)), + ("DropdownArrow", "icon-12px-solid/dropdown-arrow.svg", Some(12)), + ("Edit12px", "icon-12px-solid/edit-12px.svg", Some(12)), + ("Empty12px", "icon-12px-solid/empty-12px.svg", Some(12)), + ("Failure", "icon-12px-solid/failure.svg", Some(12)), + ("FullscreenEnter", "icon-12px-solid/fullscreen-enter.svg", Some(12)), + ("FullscreenExit", "icon-12px-solid/fullscreen-exit.svg", Some(12)), + ("Grid", "icon-12px-solid/grid.svg", Some(12)), + ("GridDotted", "icon-12px-solid/grid-dotted.svg", Some(12)), + ("Info", "icon-12px-solid/info.svg", Some(12)), + ("KeyboardArrowDown", "icon-12px-solid/keyboard-arrow-down.svg", Some(12)), + ("KeyboardArrowLeft", "icon-12px-solid/keyboard-arrow-left.svg", Some(12)), + ("KeyboardArrowRight", "icon-12px-solid/keyboard-arrow-right.svg", Some(12)), + ("KeyboardArrowUp", "icon-12px-solid/keyboard-arrow-up.svg", Some(12)), + ("KeyboardBackspace", "icon-12px-solid/keyboard-backspace.svg", Some(12)), + ("KeyboardCommand", "icon-12px-solid/keyboard-command.svg", Some(12)), + ("KeyboardControl", "icon-12px-solid/keyboard-control.svg", Some(12)), + ("KeyboardEnter", "icon-12px-solid/keyboard-enter.svg", Some(12)), + ("KeyboardOption", "icon-12px-solid/keyboard-option.svg", Some(12)), + ("KeyboardShift", "icon-12px-solid/keyboard-shift.svg", Some(12)), + ("KeyboardSpace", "icon-12px-solid/keyboard-space.svg", Some(12)), + ("KeyboardTab", "icon-12px-solid/keyboard-tab.svg", Some(12)), + ("License12px", "icon-12px-solid/license-12px.svg", Some(12)), + ("Link", "icon-12px-solid/link.svg", Some(12)), + ("Overlays", "icon-12px-solid/overlays.svg", Some(12)), + ("Remove", "icon-12px-solid/remove.svg", Some(12)), + ("RenderModeNormal", "icon-12px-solid/render-mode-normal.svg", Some(12)), + ("RenderModeOutline", "icon-12px-solid/render-mode-outline.svg", Some(12)), + ("RenderModePixels", "icon-12px-solid/render-mode-pixels.svg", Some(12)), + ("RenderModeSvg", "icon-12px-solid/render-mode-svg.svg", Some(12)), + ("Snapping", "icon-12px-solid/snapping.svg", Some(12)), + ("SwapHorizontal", "icon-12px-solid/swap-horizontal.svg", Some(12)), + ("SwapVertical", "icon-12px-solid/swap-vertical.svg", Some(12)), + ("VerticalEllipsis", "icon-12px-solid/vertical-ellipsis.svg", Some(12)), + ("Warning", "icon-12px-solid/warning.svg", Some(12)), + ("WindowButtonWinClose", "icon-12px-solid/window-button-win-close.svg", Some(12)), + ("WindowButtonWinMaximize", "icon-12px-solid/window-button-win-maximize.svg", Some(12)), + ("WindowButtonWinMinimize", "icon-12px-solid/window-button-win-minimize.svg", Some(12)), + ("WindowButtonWinRestoreDown", "icon-12px-solid/window-button-win-restore-down.svg", Some(12)), + ("WorkingColors", "icon-12px-solid/working-colors.svg", Some(12)), + ("AlignBottom", "icon-16px-solid/align-bottom.svg", Some(16)), + ("AlignHorizontalCenter", "icon-16px-solid/align-horizontal-center.svg", Some(16)), + ("AlignLeft", "icon-16px-solid/align-left.svg", Some(16)), + ("AlignRight", "icon-16px-solid/align-right.svg", Some(16)), + ("AlignTop", "icon-16px-solid/align-top.svg", Some(16)), + ("AlignVerticalCenter", "icon-16px-solid/align-vertical-center.svg", Some(16)), + ("Artboard", "icon-16px-solid/artboard.svg", Some(16)), + ("BooleanDifference", "icon-16px-solid/boolean-difference.svg", Some(16)), + ("BooleanDivide", "icon-16px-solid/boolean-divide.svg", Some(16)), + ("BooleanIntersect", "icon-16px-solid/boolean-intersect.svg", Some(16)), + ("BooleanSubtractBack", "icon-16px-solid/boolean-subtract-back.svg", Some(16)), + ("BooleanSubtractFront", "icon-16px-solid/boolean-subtract-front.svg", Some(16)), + ("BooleanUnion", "icon-16px-solid/boolean-union.svg", Some(16)), + ("Bug", "icon-16px-solid/bug.svg", Some(16)), + ("CheckboxChecked", "icon-16px-solid/checkbox-checked.svg", Some(16)), + ("CheckboxUnchecked", "icon-16px-solid/checkbox-unchecked.svg", Some(16)), + ("Close", "icon-16px-solid/close.svg", Some(16)), + ("CloseAll", "icon-16px-solid/close-all.svg", Some(16)), + ("Code", "icon-16px-solid/code.svg", Some(16)), + ("Copy", "icon-16px-solid/copy.svg", Some(16)), + ("Credits", "icon-16px-solid/credits.svg", Some(16)), + ("CustomColor", "icon-16px-solid/custom-color.svg", Some(16)), + ("Cut", "icon-16px-solid/cut.svg", Some(16)), + ("DeselectAll", "icon-16px-solid/deselect-all.svg", Some(16)), + ("Edit", "icon-16px-solid/edit.svg", Some(16)), + ("Empty", "icon-16px-solid/empty.svg", Some(16)), + ("ExpandFillStroke", "icon-16px-solid/expand-fill-stroke.svg", Some(16)), + ("Eyedropper", "icon-16px-solid/eyedropper.svg", Some(16)), + ("EyeHidden", "icon-16px-solid/eye-hidden.svg", Some(16)), + ("EyeHide", "icon-16px-solid/eye-hide.svg", Some(16)), + ("EyeShow", "icon-16px-solid/eye-show.svg", Some(16)), + ("EyeVisible", "icon-16px-solid/eye-visible.svg", Some(16)), + ("File", "icon-16px-solid/file.svg", Some(16)), + ("FileExport", "icon-16px-solid/file-export.svg", Some(16)), + ("FileImport", "icon-16px-solid/file-import.svg", Some(16)), + ("FlipHorizontal", "icon-16px-solid/flip-horizontal.svg", Some(16)), + ("FlipVertical", "icon-16px-solid/flip-vertical.svg", Some(16)), + ("Folder", "icon-16px-solid/folder.svg", Some(16)), + ("FolderOpen", "icon-16px-solid/folder-open.svg", Some(16)), + ("FrameAll", "icon-16px-solid/frame-all.svg", Some(16)), + ("FrameSelected", "icon-16px-solid/frame-selected.svg", Some(16)), + ("GraphiteLogo", "icon-16px-solid/graphite-logo.svg", Some(16)), + ("GraphViewClosed", "icon-16px-solid/graph-view-closed.svg", Some(16)), + ("GraphViewOpen", "icon-16px-solid/graph-view-open.svg", Some(16)), + ("HandleVisibilityAll", "icon-16px-solid/handle-visibility-all.svg", Some(16)), + ("HandleVisibilityFrontier", "icon-16px-solid/handle-visibility-frontier.svg", Some(16)), + ("HandleVisibilitySelected", "icon-16px-solid/handle-visibility-selected.svg", Some(16)), + ("Heart", "icon-16px-solid/heart.svg", Some(16)), + ("HistoryRedo", "icon-16px-solid/history-redo.svg", Some(16)), + ("HistoryUndo", "icon-16px-solid/history-undo.svg", Some(16)), + ("IconsGrid", "icon-16px-solid/icons-grid.svg", Some(16)), + ("Image", "icon-16px-solid/image.svg", Some(16)), + ("InterpolationBlend", "icon-16px-solid/interpolation-blend.svg", Some(16)), + ("InterpolationMorph", "icon-16px-solid/interpolation-morph.svg", Some(16)), + ("Layer", "icon-16px-solid/layer.svg", Some(16)), + ("License", "icon-16px-solid/license.svg", Some(16)), + ("NewLayer", "icon-16px-solid/new-layer.svg", Some(16)), + ("Node", "icon-16px-solid/node.svg", Some(16)), + ("NodeBlur", "icon-16px-solid/node-blur.svg", Some(16)), + ("NodeBrushwork", "icon-16px-solid/node-brushwork.svg", Some(16)), + ("NodeColorCorrection", "icon-16px-solid/node-color-correction.svg", Some(16)), + ("NodeGradient", "icon-16px-solid/node-gradient.svg", Some(16)), + ("NodeMagicWand", "icon-16px-solid/node-magic-wand.svg", Some(16)), + ("NodeMask", "icon-16px-solid/node-mask.svg", Some(16)), + ("NodeMotionBlur", "icon-16px-solid/node-motion-blur.svg", Some(16)), + ("NodeNodes", "icon-16px-solid/node-nodes.svg", Some(16)), + ("NodeOutput", "icon-16px-solid/node-output.svg", Some(16)), + ("NodeShape", "icon-16px-solid/node-shape.svg", Some(16)), + ("NodeText", "icon-16px-solid/node-text.svg", Some(16)), + ("NodeTransform", "icon-16px-solid/node-transform.svg", Some(16)), + ("PadlockLocked", "icon-16px-solid/padlock-locked.svg", Some(16)), + ("PadlockUnlocked", "icon-16px-solid/padlock-unlocked.svg", Some(16)), + ("Paste", "icon-16px-solid/paste.svg", Some(16)), + ("PinActive", "icon-16px-solid/pin-active.svg", Some(16)), + ("PinInactive", "icon-16px-solid/pin-inactive.svg", Some(16)), + ("PlaybackPause", "icon-16px-solid/playback-pause.svg", Some(16)), + ("PlaybackPlay", "icon-16px-solid/playback-play.svg", Some(16)), + ("PlaybackToEnd", "icon-16px-solid/playback-to-end.svg", Some(16)), + ("PlaybackToStart", "icon-16px-solid/playback-to-start.svg", Some(16)), + ("Random", "icon-16px-solid/random.svg", Some(16)), + ("Reload", "icon-16px-solid/reload.svg", Some(16)), + ("Reset", "icon-16px-solid/reset.svg", Some(16)), + ("Resync", "icon-16px-solid/resync.svg", Some(16)), + ("Reverse", "icon-16px-solid/reverse.svg", Some(16)), + ("ReverseRadialGradientToLeft", "icon-16px-solid/reverse-radial-gradient-to-left.svg", Some(16)), + ("ReverseRadialGradientToRight", "icon-16px-solid/reverse-radial-gradient-to-right.svg", Some(16)), + ("Save", "icon-16px-solid/save.svg", Some(16)), + ("SelectAll", "icon-16px-solid/select-all.svg", Some(16)), + ("SelectParent", "icon-16px-solid/select-parent.svg", Some(16)), + ("Settings", "icon-16px-solid/settings.svg", Some(16)), + ("SmallDot", "icon-16px-solid/small-dot.svg", Some(16)), + ("Stack", "icon-16px-solid/stack.svg", Some(16)), + ("StackBottom", "icon-16px-solid/stack-bottom.svg", Some(16)), + ("StackHollow", "icon-16px-solid/stack-hollow.svg", Some(16)), + ("StackLower", "icon-16px-solid/stack-lower.svg", Some(16)), + ("StackRaise", "icon-16px-solid/stack-raise.svg", Some(16)), + ("StackReverse", "icon-16px-solid/stack-reverse.svg", Some(16)), + ("StrokeAlignCenter", "icon-16px-solid/stroke-align-center.svg", Some(16)), + ("StrokeAlignInside", "icon-16px-solid/stroke-align-inside.svg", Some(16)), + ("StrokeAlignOutside", "icon-16px-solid/stroke-align-outside.svg", Some(16)), + ("StrokeCapButt", "icon-16px-solid/stroke-cap-butt.svg", Some(16)), + ("StrokeCapRound", "icon-16px-solid/stroke-cap-round.svg", Some(16)), + ("StrokeCapSquare", "icon-16px-solid/stroke-cap-square.svg", Some(16)), + ("StrokeJoinBevel", "icon-16px-solid/stroke-join-bevel.svg", Some(16)), + ("StrokeJoinMiter", "icon-16px-solid/stroke-join-miter.svg", Some(16)), + ("StrokeJoinRound", "icon-16px-solid/stroke-join-round.svg", Some(16)), + ("StrokeOrderAbove", "icon-16px-solid/stroke-order-above.svg", Some(16)), + ("StrokeOrderBelow", "icon-16px-solid/stroke-order-below.svg", Some(16)), + ("TextAlignCenter", "icon-16px-solid/text-align-center.svg", Some(16)), + ("TextAlignLeft", "icon-16px-solid/text-align-left.svg", Some(16)), + ("TextAlignRight", "icon-16px-solid/text-align-right.svg", Some(16)), + ("TextAlignSpineAway", "icon-16px-solid/text-align-spine-away.svg", Some(16)), + ("TextAlignSpineTowards", "icon-16px-solid/text-align-spine-towards.svg", Some(16)), + ("TextJustifyAll", "icon-16px-solid/text-justify-all.svg", Some(16)), + ("TextJustifyCenter", "icon-16px-solid/text-justify-center.svg", Some(16)), + ("TextJustifyLeft", "icon-16px-solid/text-justify-left.svg", Some(16)), + ("TextJustifyRight", "icon-16px-solid/text-justify-right.svg", Some(16)), + ("Tilt", "icon-16px-solid/tilt.svg", Some(16)), + ("TiltReset", "icon-16px-solid/tilt-reset.svg", Some(16)), + ("TransformationGrab", "icon-16px-solid/transformation-grab.svg", Some(16)), + ("TransformationRotate", "icon-16px-solid/transformation-rotate.svg", Some(16)), + ("TransformationScale", "icon-16px-solid/transformation-scale.svg", Some(16)), + ("Trash", "icon-16px-solid/trash.svg", Some(16)), + ("TurnNegative90", "icon-16px-solid/turn-negative-90.svg", Some(16)), + ("TurnPositive90", "icon-16px-solid/turn-positive-90.svg", Some(16)), + ("UserManual", "icon-16px-solid/user-manual.svg", Some(16)), + ("ViewportDesignMode", "icon-16px-solid/viewport-design-mode.svg", Some(16)), + ("ViewportGuideMode", "icon-16px-solid/viewport-guide-mode.svg", Some(16)), + ("ViewportSelectMode", "icon-16px-solid/viewport-select-mode.svg", Some(16)), + ("Volunteer", "icon-16px-solid/volunteer.svg", Some(16)), + ("Website", "icon-16px-solid/website.svg", Some(16)), + ("WorkingColorsPrimary", "icon-16px-solid/working-colors-primary.svg", Some(16)), + ("WorkingColorsSecondary", "icon-16px-solid/working-colors-secondary.svg", Some(16)), + ("Zoom1x", "icon-16px-solid/zoom-1x.svg", Some(16)), + ("Zoom2x", "icon-16px-solid/zoom-2x.svg", Some(16)), + ("ZoomIn", "icon-16px-solid/zoom-in.svg", Some(16)), + ("ZoomOut", "icon-16px-solid/zoom-out.svg", Some(16)), + ("ZoomReset", "icon-16px-solid/zoom-reset.svg", Some(16)), + ("MouseHintDrag", "icon-16px-two-tone/mouse-hint-drag.svg", Some(16)), + ("MouseHintLmb", "icon-16px-two-tone/mouse-hint-lmb.svg", Some(16)), + ("MouseHintLmbDouble", "icon-16px-two-tone/mouse-hint-lmb-double.svg", Some(16)), + ("MouseHintLmbDrag", "icon-16px-two-tone/mouse-hint-lmb-drag.svg", Some(16)), + ("MouseHintMmb", "icon-16px-two-tone/mouse-hint-mmb.svg", Some(16)), + ("MouseHintMmbDrag", "icon-16px-two-tone/mouse-hint-mmb-drag.svg", Some(16)), + ("MouseHintNone", "icon-16px-two-tone/mouse-hint-none.svg", Some(16)), + ("MouseHintRmb", "icon-16px-two-tone/mouse-hint-rmb.svg", Some(16)), + ("MouseHintRmbDouble", "icon-16px-two-tone/mouse-hint-rmb-double.svg", Some(16)), + ("MouseHintRmbDrag", "icon-16px-two-tone/mouse-hint-rmb-drag.svg", Some(16)), + ("MouseHintScrollDown", "icon-16px-two-tone/mouse-hint-scroll-down.svg", Some(16)), + ("MouseHintScrollUp", "icon-16px-two-tone/mouse-hint-scroll-up.svg", Some(16)), + ("GeneralArtboardTool", "icon-24px-two-tone/general-artboard-tool.svg", Some(24)), + ("GeneralEyedropperTool", "icon-24px-two-tone/general-eyedropper-tool.svg", Some(24)), + ("GeneralFillTool", "icon-24px-two-tone/general-fill-tool.svg", Some(24)), + ("GeneralGradientTool", "icon-24px-two-tone/general-gradient-tool.svg", Some(24)), + ("GeneralNavigateTool", "icon-24px-two-tone/general-navigate-tool.svg", Some(24)), + ("GeneralSelectTool", "icon-24px-two-tone/general-select-tool.svg", Some(24)), + ("RasterBrushTool", "icon-24px-two-tone/raster-brush-tool.svg", Some(24)), + ("RasterCloneTool", "icon-24px-two-tone/raster-clone-tool.svg", Some(24)), + ("RasterDetailTool", "icon-24px-two-tone/raster-detail-tool.svg", Some(24)), + ("RasterHealTool", "icon-24px-two-tone/raster-heal-tool.svg", Some(24)), + ("RasterPatchTool", "icon-24px-two-tone/raster-patch-tool.svg", Some(24)), + ("RasterRelightTool", "icon-24px-two-tone/raster-relight-tool.svg", Some(24)), + ("VectorEllipseTool", "icon-24px-two-tone/vector-ellipse-tool.svg", Some(24)), + ("VectorFreehandTool", "icon-24px-two-tone/vector-freehand-tool.svg", Some(24)), + ("VectorLineTool", "icon-24px-two-tone/vector-line-tool.svg", Some(24)), + ("VectorPathTool", "icon-24px-two-tone/vector-path-tool.svg", Some(24)), + ("VectorPenTool", "icon-24px-two-tone/vector-pen-tool.svg", Some(24)), + ("VectorPolygonTool", "icon-24px-two-tone/vector-polygon-tool.svg", Some(24)), + ("VectorRectangleTool", "icon-24px-two-tone/vector-rectangle-tool.svg", Some(24)), + ("VectorSplineTool", "icon-24px-two-tone/vector-spline-tool.svg", Some(24)), + ("VectorTextTool", "icon-24px-two-tone/vector-text-tool.svg", Some(24)), +]; diff --git a/frontend/iced/src/main.rs b/frontend/iced/src/main.rs index f68e607f..ba4f7db3 100644 --- a/frontend/iced/src/main.rs +++ b/frontend/iced/src/main.rs @@ -1,7 +1,14 @@ mod app; +mod clipboard; +mod file_io; +mod icons; mod input; mod layout; +mod persist; +mod pointer; mod shell; +mod window_control; + mod viewport; mod viewport_widget; mod widgets; diff --git a/frontend/iced/src/persist.rs b/frontend/iced/src/persist.rs new file mode 100644 index 00000000..55bfbeca --- /dev/null +++ b/frontend/iced/src/persist.rs @@ -0,0 +1,143 @@ +use graphite_editor::messages::frontend::utility_types::PersistedState; +use graphite_editor::messages::prelude::DocumentId; +use std::path::PathBuf; + +const APP_DIRECTORY_NAME: &str = if cfg!(target_os = "linux") { "graphite" } else { "Graphite" }; +const STATE_FILE_NAME: &str = "state.json"; +const PREFERENCES_FILE_NAME: &str = "preferences.json"; +const DOCUMENTS_DIRECTORY_NAME: &str = "documents"; +const DOCUMENT_FILE_EXTENSION: &str = "graphite"; + +fn root_dir() -> Option { + let base = dirs::config_local_dir().or_else(dirs::data_local_dir)?; + let path = base.join(APP_DIRECTORY_NAME); + if let Err(e) = std::fs::create_dir_all(&path) { + tracing::warn!("failed to create graphite config directory at {path:?}: {e}"); + return None; + } + Some(path) +} + +fn documents_dir() -> Option { + let path = root_dir()?.join(DOCUMENTS_DIRECTORY_NAME); + if let Err(e) = std::fs::create_dir_all(&path) { + tracing::warn!("failed to create documents directory at {path:?}: {e}"); + return None; + } + Some(path) +} + +fn state_path() -> Option { + Some(root_dir()?.join(STATE_FILE_NAME)) +} + +fn preferences_path() -> Option { + Some(root_dir()?.join(PREFERENCES_FILE_NAME)) +} + +fn document_path(id: DocumentId) -> Option { + Some(documents_dir()?.join(format!("{:x}.{}", id.0, DOCUMENT_FILE_EXTENSION))) +} + +pub fn read_state() -> Option { + let path = state_path()?; + let raw = match std::fs::read_to_string(&path) { + Ok(raw) => raw, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None, + Err(e) => { + tracing::warn!("failed to read persisted state from {path:?}: {e}"); + return None; + } + }; + match serde_json::from_str::(&raw) { + Ok(state) => Some(state), + Err(e) => { + tracing::warn!("failed to parse persisted state at {path:?}: {e}"); + None + } + } +} + +pub fn write_state(state: &PersistedState) { + let Some(path) = state_path() else { return }; + let serialized = match serde_json::to_string_pretty(state) { + Ok(s) => s, + Err(e) => { + tracing::warn!("failed to serialize persisted state: {e}"); + return; + } + }; + if let Err(e) = std::fs::write(&path, serialized) { + tracing::warn!("failed to write persisted state to {path:?}: {e}"); + return; + } + garbage_collect_documents(state); +} + +pub fn read_document(document_id: DocumentId) -> Option { + let path = document_path(document_id)?; + match std::fs::read_to_string(&path) { + Ok(content) => Some(content), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + tracing::warn!("failed to read document {document_id:?} from {path:?}: {e}"); + None + } + } +} + +pub fn write_document(document_id: DocumentId, content: &str) { + let Some(path) = document_path(document_id) else { return }; + if let Err(e) = std::fs::write(&path, content) { + tracing::warn!("failed to write document {document_id:?} to {path:?}: {e}"); + } +} + +pub fn delete_document(document_id: DocumentId) { + let Some(path) = document_path(document_id) else { return }; + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => tracing::warn!("failed to delete document {document_id:?} at {path:?}: {e}"), + } +} + +pub fn read_preferences() -> Option { + let path = preferences_path()?; + match std::fs::read_to_string(&path) { + Ok(raw) => Some(raw), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => { + tracing::warn!("failed to read preferences from {path:?}: {e}"); + None + } + } +} + +pub fn write_preferences(json: &str) { + let Some(path) = preferences_path() else { return }; + if let Err(e) = std::fs::write(&path, json) { + tracing::warn!("failed to write preferences to {path:?}: {e}"); + } +} + +fn garbage_collect_documents(state: &PersistedState) { + let Some(dir) = documents_dir() else { return }; + let valid: std::collections::HashSet = state.documents.iter().filter_map(|doc| document_path(doc.id)).collect(); + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return, + Err(e) => { + tracing::warn!("failed to scan documents directory at {dir:?}: {e}"); + return; + } + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && !valid.contains(&path) { + if let Err(e) = std::fs::remove_file(&path) { + tracing::warn!("failed to remove orphaned document at {path:?}: {e}"); + } + } + } +} diff --git a/frontend/iced/src/pointer.rs b/frontend/iced/src/pointer.rs new file mode 100644 index 00000000..7306ac2c --- /dev/null +++ b/frontend/iced/src/pointer.rs @@ -0,0 +1,161 @@ +use std::time::{Duration, Instant}; + +use graphite_editor::consts::DOUBLE_CLICK_MILLISECONDS; +use graphite_editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; +use graphite_editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, MouseKeys, ScrollDelta}; + +use winit::event::{ElementState, MouseButton, MouseScrollDelta}; + +pub const DEFAULT_TOOLSHELF_WIDTH: f64 = 48.0; +pub const DEFAULT_SIDEBAR_WIDTH: f64 = 340.0; +pub const DEFAULT_MENUBAR_HEIGHT: f64 = 28.0; +pub const DEFAULT_STATUSBAR_HEIGHT: f64 = 24.0; + +const SCROLL_LINE_HEIGHT: f64 = 16.0; +const SCROLL_LINE_WIDTH: f64 = 16.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ViewportRegion { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +impl ViewportRegion { + pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self { + Self { x, y, width, height } + } + + pub fn from_window(window_width: f64, window_height: f64) -> Self { + let x = DEFAULT_TOOLSHELF_WIDTH; + let y = DEFAULT_MENUBAR_HEIGHT; + let width = (window_width - DEFAULT_TOOLSHELF_WIDTH - DEFAULT_SIDEBAR_WIDTH).max(0.0); + let height = (window_height - DEFAULT_MENUBAR_HEIGHT - DEFAULT_STATUSBAR_HEIGHT).max(0.0); + Self { x, y, width, height } + } + + pub fn contains(&self, position_logical: (f64, f64)) -> bool { + let (px, py) = position_logical; + px >= self.x && py >= self.y && px < self.x + self.width && py < self.y + self.height + } + + pub fn to_viewport_local(&self, position_logical: (f64, f64)) -> (f64, f64) { + (position_logical.0 - self.x, position_logical.1 - self.y) + } +} + +impl Default for ViewportRegion { + fn default() -> Self { + Self { x: DEFAULT_TOOLSHELF_WIDTH, y: DEFAULT_MENUBAR_HEIGHT, width: 0.0, height: 0.0 } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PointerHit { + Viewport, + Chrome, +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct EditorPointerState { + buttons: u8, + last_position_logical: (f64, f64), + last_down: Option, +} + +#[derive(Debug, Clone, Copy)] +struct LastDown { + button: MouseButton, + at: Instant, + position_logical: (f64, f64), +} + +impl EditorPointerState { + pub fn new() -> Self { + Self::default() + } + + pub fn buttons(&self) -> u8 { + self.buttons + } + + pub fn mouse_keys(&self) -> MouseKeys { + MouseKeys::from_bits_truncate(self.buttons) + } + + pub fn last_position_logical(&self) -> (f64, f64) { + self.last_position_logical + } + + pub fn record_move(&mut self, position_logical: (f64, f64)) { + self.last_position_logical = position_logical; + } + + pub fn record_button(&mut self, button: MouseButton, state: ElementState, position_logical: (f64, f64)) -> ButtonTransition { + self.last_position_logical = position_logical; + let bit = translate_winit_button_to_bit(button); + match state { + ElementState::Pressed => { + self.buttons |= bit; + let now = Instant::now(); + let double = self + .last_down + .is_some_and(|prev| prev.button == button && now.duration_since(prev.at) <= Duration::from_millis(DOUBLE_CLICK_MILLISECONDS)); + self.last_down = Some(LastDown { button, at: now, position_logical }); + if double { + ButtonTransition::DownDouble + } else { + ButtonTransition::Down + } + } + ElementState::Released => { + self.buttons &= !bit; + ButtonTransition::Up + } + } + } + + pub fn classify(&self, position_logical: (f64, f64), region: ViewportRegion) -> PointerHit { + if region.contains(position_logical) { PointerHit::Viewport } else { PointerHit::Chrome } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ButtonTransition { + Down, + DownDouble, + Up, +} + +pub fn translate_winit_button_to_bit(button: MouseButton) -> u8 { + match button { + MouseButton::Left => MouseKeys::LEFT.bits(), + MouseButton::Right => MouseKeys::RIGHT.bits(), + MouseButton::Middle => MouseKeys::MIDDLE.bits(), + MouseButton::Back => MouseKeys::BACK.bits(), + MouseButton::Forward => MouseKeys::FORWARD.bits(), + MouseButton::Other(_) => 0, + } +} + +pub fn build_editor_mouse_state(position_logical: (f64, f64), buttons: u8) -> EditorMouseState { + EditorMouseState::from_keys_and_editor_position(buttons, position_logical.into()) +} + +pub fn build_editor_mouse_state_with_scroll(position_logical: (f64, f64), buttons: u8, scroll: ScrollDelta) -> EditorMouseState { + let mut state = build_editor_mouse_state(position_logical, buttons); + state.scroll_delta = scroll; + state +} + +pub fn winit_scroll_to_delta(delta: MouseScrollDelta) -> ScrollDelta { + match delta { + MouseScrollDelta::LineDelta(x, y) => ScrollDelta::new(f64::from(x) * SCROLL_LINE_WIDTH, f64::from(y) * SCROLL_LINE_HEIGHT, 0.0), + MouseScrollDelta::PixelDelta(position) => ScrollDelta::new(position.x, position.y, 0.0), + } +} + +pub fn empty_modifiers() -> ModifierKeys { + ModifierKeys::empty() +} diff --git a/frontend/iced/src/window_control.rs b/frontend/iced/src/window_control.rs new file mode 100644 index 00000000..f37212f9 --- /dev/null +++ b/frontend/iced/src/window_control.rs @@ -0,0 +1,141 @@ +use std::sync::mpsc::{Sender, channel}; + +use graphite_editor::messages::frontend::utility_types::MouseCursorIcon; +use winit::cursor::CursorIcon; +use winit::dpi::{LogicalPosition, Position}; +use winit::monitor::Fullscreen; +use winit::window::{CursorGrabMode, Window}; + +pub enum WindowCommand { + Minimize, + Maximize, + Fullscreen, + Close, + Hide, + Focus, + StartDrag, + SetTitle(String), + SetCursorIcon(CursorIcon), + SetMouseCursor(MouseCursorIcon), + PointerLock, + PointerWarp { position: (f64, f64) }, + PointerUnlock, +} + +pub struct WindowCommandSender(pub Sender); + +impl Clone for WindowCommandSender { + fn clone(&self) -> Self { + WindowCommandSender(self.0.clone()) + } +} + +impl WindowCommandSender { + pub fn send(&self, command: WindowCommand) { + if let Err(error) = self.0.send(command) { + tracing::warn!("dropped window command: {error}"); + } + } +} + +pub fn channel_pair() -> (WindowCommandSender, std::sync::mpsc::Receiver) { + let (sender, receiver) = channel(); + (WindowCommandSender(sender), receiver) +} + +pub fn handle_command(window: &dyn Window, command: WindowCommand) { + match command { + WindowCommand::Minimize => window.set_minimized(true), + WindowCommand::Maximize => { + if window.fullscreen().is_some() { + return; + } + window.set_maximized(!window.is_maximized()); + } + WindowCommand::Fullscreen => { + if window.fullscreen().is_some() { + window.set_fullscreen(None); + } else { + window.set_fullscreen(Some(Fullscreen::Borderless(None))); + } + } + WindowCommand::Close => { + tracing::info!("window close requested"); + } + WindowCommand::Hide => window.set_visible(false), + WindowCommand::Focus => { + window.set_minimized(false); + window.focus_window(); + } + WindowCommand::StartDrag => { + if window.fullscreen().is_some() { + return; + } + if let Err(error) = window.drag_window() { + tracing::debug!("drag_window failed: {error}"); + } + } + WindowCommand::SetTitle(title) => window.set_title(&title), + WindowCommand::SetCursorIcon(icon) => { + window.set_cursor_visible(true); + window.set_cursor(icon.into()); + } + WindowCommand::SetMouseCursor(cursor) => apply_mouse_cursor(window, cursor), + WindowCommand::PointerLock => { + if window.set_cursor_grab(CursorGrabMode::Locked).is_err() { + let _ = window.set_cursor_grab(CursorGrabMode::Confined); + } + window.set_cursor_visible(false); + } + WindowCommand::PointerWarp { position } => { + let pos = Position::Logical(LogicalPosition::new(position.0, position.1)); + if let Err(error) = window.set_cursor_position(pos) { + tracing::debug!("set_cursor_position failed: {error}"); + } + } + WindowCommand::PointerUnlock => { + let _ = window.set_cursor_grab(CursorGrabMode::None); + window.set_cursor_visible(true); + } + } +} + +pub fn translate_cursor(cursor: MouseCursorIcon) -> CursorIcon { + match cursor { + MouseCursorIcon::Default => CursorIcon::Default, + MouseCursorIcon::None => CursorIcon::Default, + MouseCursorIcon::ZoomIn => CursorIcon::ZoomIn, + MouseCursorIcon::ZoomOut => CursorIcon::ZoomOut, + MouseCursorIcon::Grabbing => CursorIcon::Grabbing, + MouseCursorIcon::Crosshair => CursorIcon::Crosshair, + MouseCursorIcon::Text => CursorIcon::Text, + MouseCursorIcon::Move => CursorIcon::Move, + MouseCursorIcon::NSResize => CursorIcon::NsResize, + MouseCursorIcon::EWResize => CursorIcon::EwResize, + MouseCursorIcon::NESWResize => CursorIcon::NeswResize, + MouseCursorIcon::NWSEResize => CursorIcon::NwseResize, + MouseCursorIcon::Rotate => CursorIcon::Alias, + } +} + +fn apply_mouse_cursor(window: &dyn Window, cursor: MouseCursorIcon) { + if matches!(cursor, MouseCursorIcon::None) { + window.set_cursor_visible(false); + return; + } + window.set_cursor_visible(true); + window.set_cursor(translate_cursor(cursor).into()); +} + +pub fn format_window_title(active_doc_name: Option<&str>) -> String { + match active_doc_name { + Some(name) if !name.is_empty() => format!("{name} \u{2013} Graphite"), + _ => String::from("Graphite"), + } +} + +pub fn drain_into(receiver: &std::sync::mpsc::Receiver, mut apply: F) { + while let Ok(command) = receiver.try_recv() { + apply(command); + } +}