feat: 初始化项目基础架构和核心功能
添加项目基础文件结构,包括Tauri配置、前端框架和核心功能模块 实现WebGPU渲染器基础、纹理管理和着色器 添加UI组件包括标题栏、确认对话框、帮助窗口等 设置状态管理存储和工具函数 配置构建工具和开发环境
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
6250
src-tauri/Cargo.lock
generated
Normal file
41
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "refvroid"
|
||||
version = "0.1.0"
|
||||
description = "RefVroid - Animation Reference Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "refvroid_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
image = "0.25"
|
||||
arboard = "3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_Foundation",
|
||||
"Win32_System_DataExchange",
|
||||
"Win32_System_Ole",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_System_Memory"
|
||||
] }
|
||||
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
40
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:allow-is-always-on-top",
|
||||
"core:window:allow-set-decorations",
|
||||
"core:window:allow-set-ignore-cursor-events",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-is-minimized",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-is-visible",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:event:default",
|
||||
"core:event:allow-emit",
|
||||
"core:event:allow-listen",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"fs:default",
|
||||
"fs:read-all",
|
||||
"fs:write-all",
|
||||
"clipboard-manager:default",
|
||||
"clipboard-manager:allow-read-text",
|
||||
"clipboard-manager:allow-read-image",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"clipboard-manager:allow-write-image"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
504
src-tauri/src/commands/clipboard.rs
Normal file
@@ -0,0 +1,504 @@
|
||||
// Clipboard operations commands for RefVroid
|
||||
// Handles reading/writing images, text, and files to system clipboard
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use image::ImageEncoder;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::ffi::OsString;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
|
||||
/// Clipboard content types
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
pub enum ClipboardContent {
|
||||
#[serde(rename = "image")]
|
||||
Image(String), // base64 encoded image with data URL prefix
|
||||
#[serde(rename = "text")]
|
||||
Text(String),
|
||||
#[serde(rename = "files")]
|
||||
Files(Vec<String>), // file paths
|
||||
#[serde(rename = "empty")]
|
||||
Empty,
|
||||
}
|
||||
|
||||
/// Read file list from Windows clipboard (CF_HDROP format)
|
||||
#[cfg(target_os = "windows")]
|
||||
fn read_clipboard_files_windows() -> Option<Vec<String>> {
|
||||
use windows::Win32::Foundation::{HWND, HGLOBAL};
|
||||
use windows::Win32::System::DataExchange::{OpenClipboard, CloseClipboard, GetClipboardData};
|
||||
use windows::Win32::System::Ole::CF_HDROP;
|
||||
use windows::Win32::UI::Shell::{DragQueryFileW, HDROP};
|
||||
use windows::Win32::System::Memory::{GlobalLock, GlobalUnlock};
|
||||
|
||||
unsafe {
|
||||
if OpenClipboard(HWND::default()).is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let result = (|| {
|
||||
let handle = GetClipboardData(CF_HDROP.0 as u32).ok()?;
|
||||
let hglobal = HGLOBAL(handle.0);
|
||||
let ptr = GlobalLock(hglobal);
|
||||
|
||||
if ptr.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hdrop = HDROP(ptr as _);
|
||||
|
||||
// Get file count
|
||||
let count = DragQueryFileW(hdrop, 0xFFFFFFFF, None);
|
||||
if count == 0 {
|
||||
let _ = GlobalUnlock(hglobal);
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
for i in 0..count {
|
||||
// Get required buffer size
|
||||
let len = DragQueryFileW(hdrop, i, None) as usize;
|
||||
if len == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Allocate buffer and get file path
|
||||
let mut buffer: Vec<u16> = vec![0; len + 1];
|
||||
DragQueryFileW(hdrop, i, Some(&mut buffer));
|
||||
|
||||
// Convert to String
|
||||
let path = OsString::from_wide(&buffer[..len]);
|
||||
if let Some(path_str) = path.to_str() {
|
||||
files.push(path_str.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let _ = GlobalUnlock(hglobal);
|
||||
|
||||
if files.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(files)
|
||||
}
|
||||
})();
|
||||
|
||||
let _ = CloseClipboard();
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn read_clipboard_files_windows() -> Option<Vec<String>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Read clipboard contents - supports images, text, and file paths
|
||||
#[tauri::command]
|
||||
pub async fn read_clipboard() -> Result<ClipboardContent, String> {
|
||||
use arboard::Clipboard;
|
||||
|
||||
// First, try to get file list from Windows clipboard (CF_HDROP)
|
||||
// This preserves GIF animation by reading from original file
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(files) = read_clipboard_files_windows() {
|
||||
return Ok(ClipboardContent::Files(files));
|
||||
}
|
||||
|
||||
let mut clipboard = Clipboard::new()
|
||||
.map_err(|e| format!("Failed to access clipboard: {}", e))?;
|
||||
|
||||
// Try to get text and check if it contains file paths
|
||||
if let Ok(text) = clipboard.get_text() {
|
||||
if !text.is_empty() {
|
||||
// Check if text is a file path or list of file paths
|
||||
let paths: Vec<&str> = text.lines()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
// Check if all lines are valid file paths
|
||||
let all_files = !paths.is_empty() && paths.iter().all(|p| {
|
||||
// Handle file:// URLs
|
||||
let clean_path = if p.starts_with("file://") {
|
||||
p.trim_start_matches("file://")
|
||||
} else {
|
||||
p
|
||||
};
|
||||
let path = Path::new(clean_path);
|
||||
path.exists() && path.is_file()
|
||||
});
|
||||
|
||||
if all_files {
|
||||
let file_paths: Vec<String> = paths.iter()
|
||||
.map(|p| {
|
||||
if p.starts_with("file://") {
|
||||
p.trim_start_matches("file://").to_string()
|
||||
} else {
|
||||
p.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
return Ok(ClipboardContent::Files(file_paths));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get image (for screenshots, copied images from browsers, etc.)
|
||||
if let Ok(img) = clipboard.get_image() {
|
||||
// Convert to PNG and base64 encode
|
||||
let mut png_data = Vec::new();
|
||||
let encoder = image::codecs::png::PngEncoder::new(&mut png_data);
|
||||
encoder.write_image(
|
||||
&img.bytes,
|
||||
img.width as u32,
|
||||
img.height as u32,
|
||||
image::ExtendedColorType::Rgba8,
|
||||
).map_err(|e| format!("Failed to encode image: {}", e))?;
|
||||
|
||||
let base64_data = BASE64.encode(&png_data);
|
||||
return Ok(ClipboardContent::Image(format!("data:image/png;base64,{}", base64_data)));
|
||||
}
|
||||
|
||||
// Try to get text (for regular text content)
|
||||
if let Ok(text) = clipboard.get_text() {
|
||||
if !text.is_empty() {
|
||||
return Ok(ClipboardContent::Text(text));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ClipboardContent::Empty)
|
||||
}
|
||||
|
||||
/// Write image data to clipboard (from raw bytes)
|
||||
#[tauri::command]
|
||||
pub async fn write_clipboard_image(data: Vec<u8>) -> Result<(), String> {
|
||||
use arboard::Clipboard;
|
||||
|
||||
let img = image::load_from_memory(&data)
|
||||
.map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||
let rgba = img.to_rgba8();
|
||||
|
||||
let mut clipboard = Clipboard::new()
|
||||
.map_err(|e| format!("Failed to access clipboard: {}", e))?;
|
||||
|
||||
let img_data = arboard::ImageData {
|
||||
width: rgba.width() as usize,
|
||||
height: rgba.height() as usize,
|
||||
bytes: rgba.into_raw().into(),
|
||||
};
|
||||
|
||||
clipboard.set_image(img_data)
|
||||
.map_err(|e| format!("Failed to set clipboard image: {}", e))
|
||||
}
|
||||
|
||||
/// Write image from base64 string to clipboard
|
||||
#[tauri::command]
|
||||
pub async fn write_clipboard_image_base64(base64_data: String) -> Result<(), String> {
|
||||
// Remove data URL prefix if present
|
||||
let base64_str = if base64_data.contains(",") {
|
||||
base64_data.split(',').nth(1).unwrap_or(&base64_data)
|
||||
} else {
|
||||
&base64_data
|
||||
};
|
||||
|
||||
let data = BASE64.decode(base64_str)
|
||||
.map_err(|e| format!("Failed to decode base64: {}", e))?;
|
||||
|
||||
write_clipboard_image(data).await
|
||||
}
|
||||
|
||||
/// Write files to clipboard - reads file and copies as image if it's an image file
|
||||
/// For GIF files, preserves the original GIF data to maintain animation
|
||||
#[tauri::command]
|
||||
pub async fn write_clipboard_files(paths: Vec<String>) -> Result<(), String> {
|
||||
use arboard::Clipboard;
|
||||
|
||||
if paths.is_empty() {
|
||||
return Err("No files provided".to_string());
|
||||
}
|
||||
|
||||
// For single image file, copy as image to clipboard
|
||||
if paths.len() == 1 {
|
||||
let path = &paths[0];
|
||||
let extension = Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Check if it's an image file
|
||||
if matches!(extension.as_str(), "png" | "jpg" | "jpeg" | "bmp" | "webp") {
|
||||
let data = fs::read(path)
|
||||
.map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
return write_clipboard_image(data).await;
|
||||
}
|
||||
|
||||
// For GIF files, we need special handling to preserve animation
|
||||
if extension == "gif" {
|
||||
let data = fs::read(path)
|
||||
.map_err(|e| format!("Failed to read GIF file: {}", e))?;
|
||||
|
||||
// Decode GIF and get first frame for clipboard (clipboard doesn't support animated GIF)
|
||||
let img = image::load_from_memory(&data)
|
||||
.map_err(|e| format!("Failed to decode GIF: {}", e))?;
|
||||
let rgba = img.to_rgba8();
|
||||
|
||||
let mut clipboard = Clipboard::new()
|
||||
.map_err(|e| format!("Failed to access clipboard: {}", e))?;
|
||||
|
||||
let img_data = arboard::ImageData {
|
||||
width: rgba.width() as usize,
|
||||
height: rgba.height() as usize,
|
||||
bytes: rgba.into_raw().into(),
|
||||
};
|
||||
|
||||
clipboard.set_image(img_data)
|
||||
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// For multiple files or non-image files, copy paths as text
|
||||
let mut clipboard = Clipboard::new()
|
||||
.map_err(|e| format!("Failed to access clipboard: {}", e))?;
|
||||
|
||||
let text = paths.join("\n");
|
||||
clipboard.set_text(text)
|
||||
.map_err(|e| format!("Failed to set clipboard text: {}", e))
|
||||
}
|
||||
|
||||
/// Write text to clipboard
|
||||
#[tauri::command]
|
||||
pub async fn write_clipboard_text(text: String) -> Result<(), String> {
|
||||
use arboard::Clipboard;
|
||||
|
||||
let mut clipboard = Clipboard::new()
|
||||
.map_err(|e| format!("Failed to access clipboard: {}", e))?;
|
||||
|
||||
clipboard.set_text(text)
|
||||
.map_err(|e| format!("Failed to set clipboard text: {}", e))
|
||||
}
|
||||
|
||||
/// Write multiple images to clipboard as temporary files
|
||||
/// This preserves GIF animation and allows copying multiple images
|
||||
#[tauri::command]
|
||||
pub async fn write_clipboard_images_as_files(images: Vec<(String, String)>) -> Result<(), String> {
|
||||
use std::env;
|
||||
|
||||
if images.is_empty() {
|
||||
return Err("No images provided".to_string());
|
||||
}
|
||||
|
||||
let temp_dir = env::temp_dir().join("refvroid_clipboard");
|
||||
fs::create_dir_all(&temp_dir)
|
||||
.map_err(|e| format!("Failed to create temp directory: {}", e))?;
|
||||
|
||||
let mut file_paths = Vec::new();
|
||||
|
||||
for (index, (base64_data, file_type)) in images.iter().enumerate() {
|
||||
// Remove data URL prefix if present
|
||||
let (mime_type, base64_str) = if base64_data.contains(",") {
|
||||
let parts: Vec<&str> = base64_data.splitn(2, ',').collect();
|
||||
let mime = parts[0]
|
||||
.trim_start_matches("data:")
|
||||
.trim_end_matches(";base64");
|
||||
(mime.to_string(), parts.get(1).unwrap_or(&"").to_string())
|
||||
} else {
|
||||
(file_type.clone(), base64_data.clone())
|
||||
};
|
||||
|
||||
let data = BASE64.decode(&base64_str)
|
||||
.map_err(|e| format!("Failed to decode base64: {}", e))?;
|
||||
|
||||
// Determine file extension from MIME type or detect from content
|
||||
let ext = if let Some(detected) = detect_image_mime_type(&data) {
|
||||
match detected {
|
||||
"image/gif" => "gif",
|
||||
"image/png" => "png",
|
||||
"image/jpeg" => "jpg",
|
||||
"image/bmp" => "bmp",
|
||||
"image/webp" => "webp",
|
||||
_ => "png",
|
||||
}
|
||||
} else {
|
||||
match mime_type.as_str() {
|
||||
"image/gif" => "gif",
|
||||
"image/png" => "png",
|
||||
"image/jpeg" => "jpg",
|
||||
"image/bmp" => "bmp",
|
||||
"image/webp" => "webp",
|
||||
_ => "png",
|
||||
}
|
||||
};
|
||||
|
||||
let file_name = format!("clipboard_{}_{}.{}", std::process::id(), index, ext);
|
||||
let file_path = temp_dir.join(&file_name);
|
||||
|
||||
fs::write(&file_path, &data)
|
||||
.map_err(|e| format!("Failed to write temp file: {}", e))?;
|
||||
|
||||
file_paths.push(file_path.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
// Write file paths to clipboard
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
write_files_to_clipboard_windows(&file_paths)?;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Fallback: write paths as text
|
||||
use arboard::Clipboard;
|
||||
let mut clipboard = Clipboard::new()
|
||||
.map_err(|e| format!("Failed to access clipboard: {}", e))?;
|
||||
clipboard.set_text(file_paths.join("\n"))
|
||||
.map_err(|e| format!("Failed to set clipboard text: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write file paths to Windows clipboard in CF_HDROP format
|
||||
#[cfg(target_os = "windows")]
|
||||
fn write_files_to_clipboard_windows(paths: &[String]) -> Result<(), String> {
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::System::DataExchange::{OpenClipboard, CloseClipboard, EmptyClipboard, SetClipboardData};
|
||||
use windows::Win32::System::Ole::CF_HDROP;
|
||||
use windows::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE, GLOBAL_ALLOC_FLAGS};
|
||||
use std::ptr;
|
||||
|
||||
// Calculate required buffer size
|
||||
// DROPFILES structure + null-terminated wide strings + final null
|
||||
let dropfiles_size = std::mem::size_of::<DROPFILES>();
|
||||
let mut total_size = dropfiles_size;
|
||||
|
||||
for path in paths {
|
||||
// Each path as wide string + null terminator
|
||||
total_size += (path.len() + 1) * 2;
|
||||
}
|
||||
total_size += 2; // Final null terminator
|
||||
|
||||
unsafe {
|
||||
if OpenClipboard(HWND::default()).is_err() {
|
||||
return Err("Failed to open clipboard".to_string());
|
||||
}
|
||||
|
||||
let result = (|| -> Result<(), String> {
|
||||
if EmptyClipboard().is_err() {
|
||||
return Err("Failed to empty clipboard".to_string());
|
||||
}
|
||||
|
||||
let hglobal = GlobalAlloc(GMEM_MOVEABLE, total_size)
|
||||
.map_err(|_| "Failed to allocate global memory".to_string())?;
|
||||
|
||||
let ptr = GlobalLock(hglobal);
|
||||
if ptr.is_null() {
|
||||
return Err("Failed to lock global memory".to_string());
|
||||
}
|
||||
|
||||
// Fill DROPFILES structure
|
||||
let dropfiles = ptr as *mut DROPFILES;
|
||||
(*dropfiles).pFiles = dropfiles_size as u32;
|
||||
(*dropfiles).pt.x = 0;
|
||||
(*dropfiles).pt.y = 0;
|
||||
(*dropfiles).fNC = false.into();
|
||||
(*dropfiles).fWide = true.into(); // Unicode
|
||||
|
||||
// Write file paths
|
||||
let mut offset = dropfiles_size;
|
||||
for path in paths {
|
||||
let wide: Vec<u16> = path.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let dest = (ptr as *mut u8).add(offset) as *mut u16;
|
||||
ptr::copy_nonoverlapping(wide.as_ptr(), dest, wide.len());
|
||||
offset += wide.len() * 2;
|
||||
}
|
||||
|
||||
// Final null terminator
|
||||
let dest = (ptr as *mut u8).add(offset) as *mut u16;
|
||||
*dest = 0;
|
||||
|
||||
let _ = GlobalUnlock(hglobal);
|
||||
|
||||
if SetClipboardData(CF_HDROP.0 as u32, windows::Win32::Foundation::HANDLE(hglobal.0)).is_err() {
|
||||
return Err("Failed to set clipboard data".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
let _ = CloseClipboard();
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[repr(C)]
|
||||
struct DROPFILES {
|
||||
pFiles: u32,
|
||||
pt: windows::Win32::Foundation::POINT,
|
||||
fNC: windows::Win32::Foundation::BOOL,
|
||||
fWide: windows::Win32::Foundation::BOOL,
|
||||
}
|
||||
|
||||
/// Detect image MIME type from file content (magic bytes)
|
||||
fn detect_image_mime_type(data: &[u8]) -> Option<&'static str> {
|
||||
if data.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check magic bytes
|
||||
if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
|
||||
return Some("image/gif");
|
||||
}
|
||||
if data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
|
||||
return Some("image/png");
|
||||
}
|
||||
if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
return Some("image/jpeg");
|
||||
}
|
||||
if data.starts_with(b"BM") {
|
||||
return Some("image/bmp");
|
||||
}
|
||||
if data.starts_with(b"RIFF") && data.len() >= 12 && &data[8..12] == b"WEBP" {
|
||||
return Some("image/webp");
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Read image from file and return as base64 data URL
|
||||
#[tauri::command]
|
||||
pub async fn read_image_as_base64(path: String) -> Result<String, String> {
|
||||
let data = fs::read(&path)
|
||||
.map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
|
||||
// First try to detect MIME type from file content (magic bytes)
|
||||
let mime_type = if let Some(detected) = detect_image_mime_type(&data) {
|
||||
detected
|
||||
} else {
|
||||
// Fallback to extension-based detection
|
||||
let extension = Path::new(&path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
match extension.as_str() {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"bmp" => "image/bmp",
|
||||
"webp" => "image/webp",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
};
|
||||
|
||||
let base64_data = BASE64.encode(&data);
|
||||
Ok(format!("data:{};base64,{}", mime_type, base64_data))
|
||||
}
|
||||
181
src-tauri/src/commands/file.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
// File operations commands for RefVroid
|
||||
// Handles scene save/load and file reading operations
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri_plugin_dialog::{DialogExt, FilePath};
|
||||
use tauri::AppHandle;
|
||||
|
||||
/// Result of a file dialog operation
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct FileDialogResult {
|
||||
pub path: Option<String>,
|
||||
pub cancelled: bool,
|
||||
}
|
||||
|
||||
/// Save scene data to a file
|
||||
#[tauri::command]
|
||||
pub async fn save_scene(path: String, data: String) -> Result<(), String> {
|
||||
fs::write(&path, data).map_err(|e| format!("Failed to save scene: {}", e))
|
||||
}
|
||||
|
||||
/// Load scene data from a file
|
||||
#[tauri::command]
|
||||
pub async fn load_scene(path: String) -> Result<String, String> {
|
||||
fs::read_to_string(&path).map_err(|e| format!("Failed to load scene: {}", e))
|
||||
}
|
||||
|
||||
/// Read a file and return its contents as base64
|
||||
#[tauri::command]
|
||||
pub async fn read_file_as_base64(path: String) -> Result<String, String> {
|
||||
let file_path = Path::new(&path);
|
||||
let data = fs::read(file_path).map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
Ok(BASE64.encode(&data))
|
||||
}
|
||||
|
||||
/// Show save file dialog for .purgif files
|
||||
#[tauri::command]
|
||||
pub async fn show_save_dialog(app: AppHandle, default_name: Option<String>) -> Result<FileDialogResult, String> {
|
||||
let file_name = default_name.unwrap_or_else(|| "scene.purgif".to_string());
|
||||
|
||||
let file_path = app.dialog()
|
||||
.file()
|
||||
.set_file_name(&file_name)
|
||||
.add_filter("RefVroid Scene", &["purgif"])
|
||||
.add_filter("All Files", &["*"])
|
||||
.blocking_save_file();
|
||||
|
||||
match file_path {
|
||||
Some(path) => {
|
||||
let path_str = match path {
|
||||
FilePath::Path(p) => p.to_string_lossy().to_string(),
|
||||
FilePath::Url(u) => u.path().to_string(),
|
||||
};
|
||||
Ok(FileDialogResult {
|
||||
path: Some(path_str),
|
||||
cancelled: false,
|
||||
})
|
||||
}
|
||||
None => Ok(FileDialogResult {
|
||||
path: None,
|
||||
cancelled: true,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Show open file dialog for .purgif files
|
||||
#[tauri::command]
|
||||
pub async fn show_open_dialog(app: AppHandle) -> Result<FileDialogResult, String> {
|
||||
let file_path = app.dialog()
|
||||
.file()
|
||||
.add_filter("RefVroid Scene", &["purgif"])
|
||||
.add_filter("All Files", &["*"])
|
||||
.blocking_pick_file();
|
||||
|
||||
match file_path {
|
||||
Some(path) => {
|
||||
let path_str = match path {
|
||||
FilePath::Path(p) => p.to_string_lossy().to_string(),
|
||||
FilePath::Url(u) => u.path().to_string(),
|
||||
};
|
||||
Ok(FileDialogResult {
|
||||
path: Some(path_str),
|
||||
cancelled: false,
|
||||
})
|
||||
}
|
||||
None => Ok(FileDialogResult {
|
||||
path: None,
|
||||
cancelled: true,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Show open file dialog for media files (images, videos)
|
||||
#[tauri::command]
|
||||
pub async fn show_open_media_dialog(app: AppHandle) -> Result<FileDialogResult, String> {
|
||||
let file_path = app.dialog()
|
||||
.file()
|
||||
.add_filter("Images", &["png", "jpg", "jpeg", "gif", "bmp", "webp"])
|
||||
.add_filter("Videos", &["mp4", "webm", "mov"])
|
||||
.add_filter("All Files", &["*"])
|
||||
.blocking_pick_file();
|
||||
|
||||
match file_path {
|
||||
Some(path) => {
|
||||
let path_str = match path {
|
||||
FilePath::Path(p) => p.to_string_lossy().to_string(),
|
||||
FilePath::Url(u) => u.path().to_string(),
|
||||
};
|
||||
Ok(FileDialogResult {
|
||||
path: Some(path_str),
|
||||
cancelled: false,
|
||||
})
|
||||
}
|
||||
None => Ok(FileDialogResult {
|
||||
path: None,
|
||||
cancelled: true,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a multi-file dialog operation
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct MultiFileDialogResult {
|
||||
pub paths: Option<Vec<String>>,
|
||||
pub cancelled: bool,
|
||||
}
|
||||
|
||||
/// Show open file dialog for images (multiple selection)
|
||||
#[tauri::command]
|
||||
pub async fn show_open_images_dialog(app: AppHandle) -> Result<MultiFileDialogResult, String> {
|
||||
let file_paths = app.dialog()
|
||||
.file()
|
||||
.add_filter("Images", &["png", "jpg", "jpeg", "gif", "bmp", "webp"])
|
||||
.add_filter("All Files", &["*"])
|
||||
.blocking_pick_files();
|
||||
|
||||
match file_paths {
|
||||
Some(paths) => {
|
||||
let path_strs: Vec<String> = paths.iter().map(|path| {
|
||||
match path {
|
||||
FilePath::Path(p) => p.to_string_lossy().to_string(),
|
||||
FilePath::Url(u) => u.path().to_string(),
|
||||
}
|
||||
}).collect();
|
||||
Ok(MultiFileDialogResult {
|
||||
paths: Some(path_strs),
|
||||
cancelled: false,
|
||||
})
|
||||
}
|
||||
None => Ok(MultiFileDialogResult {
|
||||
paths: None,
|
||||
cancelled: true,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get file extension from path
|
||||
#[tauri::command]
|
||||
pub fn get_file_extension(path: String) -> Option<String> {
|
||||
Path::new(&path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|s| s.to_lowercase())
|
||||
}
|
||||
|
||||
/// Check if file exists
|
||||
#[tauri::command]
|
||||
pub fn file_exists(path: String) -> bool {
|
||||
Path::new(&path).exists()
|
||||
}
|
||||
|
||||
/// Get file name from path
|
||||
#[tauri::command]
|
||||
pub fn get_file_name(path: String) -> Option<String> {
|
||||
Path::new(&path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
26
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
// Tauri Commands Module
|
||||
// This module organizes all Tauri commands for the RefVroid application
|
||||
|
||||
pub mod file;
|
||||
pub mod clipboard;
|
||||
pub mod window;
|
||||
|
||||
// Re-export all commands for easy access
|
||||
pub use file::{
|
||||
save_scene, load_scene, read_file_as_base64,
|
||||
show_save_dialog, show_open_dialog, show_open_media_dialog,
|
||||
show_open_images_dialog,
|
||||
get_file_extension, file_exists, get_file_name,
|
||||
};
|
||||
pub use clipboard::{
|
||||
read_clipboard, write_clipboard_image, write_clipboard_image_base64,
|
||||
write_clipboard_files, write_clipboard_text, write_clipboard_images_as_files,
|
||||
read_image_as_base64,
|
||||
};
|
||||
pub use window::{
|
||||
set_always_on_top, get_always_on_top, set_window_opacity,
|
||||
lock_window, unlock_window, is_window_locked, is_maximized,
|
||||
toggle_maximize, minimize_window, close_window,
|
||||
show_window, hide_window, get_window_state, start_window_drag,
|
||||
move_window_by,
|
||||
};
|
||||
312
src-tauri/src/commands/window.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
// Window management commands for RefVroid
|
||||
// Handles window state, opacity, and lock/unlock functionality
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
|
||||
// Global state for window lock status
|
||||
static IS_WINDOW_LOCKED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
// Global state for window opacity (stored as 0-255)
|
||||
static WINDOW_OPACITY: AtomicU8 = AtomicU8::new(255);
|
||||
|
||||
// Flag to cancel ongoing opacity animation
|
||||
static CANCEL_OPACITY_ANIMATION: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Helper function to set window locked state (used by tray menu)
|
||||
pub fn set_window_locked_state(locked: bool) {
|
||||
IS_WINDOW_LOCKED.store(locked, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Helper function to get window locked state (used by tray menu)
|
||||
pub fn is_window_locked_state() -> bool {
|
||||
IS_WINDOW_LOCKED.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Window state information
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WindowState {
|
||||
pub is_always_on_top: bool,
|
||||
pub is_locked: bool,
|
||||
pub opacity: f64,
|
||||
pub is_maximized: bool,
|
||||
pub is_minimized: bool,
|
||||
}
|
||||
|
||||
/// Set window always on top state
|
||||
#[tauri::command]
|
||||
pub async fn set_always_on_top(app: AppHandle, value: bool) -> Result<(), String> {
|
||||
let window = app.get_webview_window("main")
|
||||
.ok_or("Main window not found")?;
|
||||
window.set_always_on_top(value)
|
||||
.map_err(|e| format!("Failed to set always on top: {}", e))
|
||||
}
|
||||
|
||||
/// Get window always on top state
|
||||
#[tauri::command]
|
||||
pub async fn get_always_on_top(app: AppHandle) -> Result<bool, String> {
|
||||
let window = app.get_webview_window("main")
|
||||
.ok_or("Main window not found")?;
|
||||
window.is_always_on_top()
|
||||
.map_err(|e| format!("Failed to get always on top state: {}", e))
|
||||
}
|
||||
|
||||
/// Set window opacity (0.1 - 1.0)
|
||||
/// Uses Windows API SetLayeredWindowAttributes for true window transparency
|
||||
#[tauri::command]
|
||||
pub async fn set_window_opacity(app: AppHandle, opacity: f64) -> Result<(), String> {
|
||||
let window = app.get_webview_window("main")
|
||||
.ok_or("Main window not found")?;
|
||||
|
||||
// Cancel any ongoing opacity animation
|
||||
CANCEL_OPACITY_ANIMATION.store(true, Ordering::SeqCst);
|
||||
|
||||
// Clamp opacity between 0.1 and 1.0
|
||||
let clamped_opacity = opacity.max(0.1).min(1.0);
|
||||
let alpha = (clamped_opacity * 255.0) as u8;
|
||||
|
||||
// Store the opacity value for later use (e.g., after maximize/restore)
|
||||
WINDOW_OPACITY.store(alpha, Ordering::SeqCst);
|
||||
|
||||
apply_window_opacity_internal(&window, alpha)
|
||||
}
|
||||
|
||||
/// Internal function to apply opacity without storing
|
||||
fn apply_window_opacity_internal(window: &tauri::WebviewWindow, alpha: u8) -> Result<(), String> {
|
||||
// Use Windows API for true window transparency
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
SetLayeredWindowAttributes, SetWindowLongPtrW, GetWindowLongPtrW,
|
||||
GWL_EXSTYLE, WS_EX_LAYERED, LWA_ALPHA,
|
||||
};
|
||||
use windows::Win32::Foundation::HWND;
|
||||
|
||||
let hwnd = window.hwnd()
|
||||
.map_err(|e| format!("Failed to get window handle: {}", e))?;
|
||||
let hwnd = HWND(hwnd.0 as *mut std::ffi::c_void);
|
||||
|
||||
unsafe {
|
||||
// Add WS_EX_LAYERED style if not already present
|
||||
let ex_style = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
|
||||
if ex_style & WS_EX_LAYERED.0 as isize == 0 {
|
||||
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex_style | WS_EX_LAYERED.0 as isize);
|
||||
}
|
||||
|
||||
// Set window alpha (0-255)
|
||||
SetLayeredWindowAttributes(hwnd, None, alpha, LWA_ALPHA)
|
||||
.map_err(|e| format!("Failed to set window opacity: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Fallback for other platforms - use CSS opacity
|
||||
let clamped_opacity = alpha as f64 / 255.0;
|
||||
let js = format!(
|
||||
"document.body.style.opacity = '{}';",
|
||||
clamped_opacity
|
||||
);
|
||||
window.eval(&js)
|
||||
.map_err(|e| format!("Failed to set window opacity: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lock window - disable mouse interactions
|
||||
/// Window will ignore all cursor events when locked
|
||||
#[tauri::command]
|
||||
pub async fn lock_window(app: AppHandle) -> Result<(), String> {
|
||||
let window = app.get_webview_window("main")
|
||||
.ok_or("Main window not found")?;
|
||||
|
||||
// Set window to ignore cursor events
|
||||
window.set_ignore_cursor_events(true)
|
||||
.map_err(|e| format!("Failed to lock window: {}", e))?;
|
||||
|
||||
IS_WINDOW_LOCKED.store(true, Ordering::SeqCst);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unlock window - enable mouse interactions
|
||||
#[tauri::command]
|
||||
pub async fn unlock_window(app: AppHandle) -> Result<(), String> {
|
||||
let window = app.get_webview_window("main")
|
||||
.ok_or("Main window not found")?;
|
||||
|
||||
window.set_ignore_cursor_events(false)
|
||||
.map_err(|e| format!("Failed to unlock window: {}", e))?;
|
||||
|
||||
IS_WINDOW_LOCKED.store(false, Ordering::SeqCst);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if window is locked
|
||||
#[tauri::command]
|
||||
pub fn is_window_locked() -> bool {
|
||||
IS_WINDOW_LOCKED.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Check if window is maximized
|
||||
#[tauri::command]
|
||||
pub async fn is_maximized(app: AppHandle) -> Result<bool, String> {
|
||||
let window = app.get_webview_window("main")
|
||||
.ok_or("Main window not found")?;
|
||||
|
||||
window.is_maximized()
|
||||
.map_err(|e| format!("Failed to get maximized state: {}", e))
|
||||
}
|
||||
|
||||
/// Toggle window maximized state
|
||||
#[tauri::command]
|
||||
pub async fn toggle_maximize(app: AppHandle) -> Result<bool, String> {
|
||||
let window = app.get_webview_window("main")
|
||||
.ok_or("Main window not found")?;
|
||||
|
||||
let is_maximized = window.is_maximized()
|
||||
.map_err(|e| format!("Failed to get maximized state: {}", e))?;
|
||||
|
||||
// Get current opacity before state change
|
||||
let target_alpha = WINDOW_OPACITY.load(Ordering::SeqCst);
|
||||
|
||||
if is_maximized {
|
||||
window.unmaximize()
|
||||
.map_err(|e| format!("Failed to unmaximize window: {}", e))?;
|
||||
} else {
|
||||
window.maximize()
|
||||
.map_err(|e| format!("Failed to maximize window: {}", e))?;
|
||||
}
|
||||
|
||||
// Re-apply opacity with smooth transition if not fully opaque
|
||||
if target_alpha < 255 {
|
||||
let window_clone = window.clone();
|
||||
std::thread::spawn(move || {
|
||||
// Reset cancel flag before starting animation
|
||||
CANCEL_OPACITY_ANIMATION.store(false, Ordering::SeqCst);
|
||||
|
||||
// Wait for window state to settle
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
// Check if cancelled during wait
|
||||
if CANCEL_OPACITY_ANIMATION.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate from fully opaque to target opacity
|
||||
// 180 steps * 16ms = ~3 seconds total duration
|
||||
let steps = 30;
|
||||
let step_delay = std::time::Duration::from_millis(16); // ~60fps
|
||||
|
||||
for i in 0..=steps {
|
||||
// Check if animation should be cancelled
|
||||
if CANCEL_OPACITY_ANIMATION.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
let progress = i as f64 / steps as f64;
|
||||
// Linear: constant speed
|
||||
let current = 255.0 - (255.0 - target_alpha as f64) * progress;
|
||||
let _ = apply_window_opacity_internal(&window_clone, current as u8);
|
||||
if i < steps {
|
||||
std::thread::sleep(step_delay);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(!is_maximized)
|
||||
}
|
||||
|
||||
/// Minimize window
|
||||
#[tauri::command]
|
||||
pub async fn minimize_window(app: AppHandle) -> Result<(), String> {
|
||||
let window = app.get_webview_window("main")
|
||||
.ok_or("Main window not found")?;
|
||||
|
||||
window.minimize()
|
||||
.map_err(|e| format!("Failed to minimize window: {}", e))
|
||||
}
|
||||
|
||||
/// Close window
|
||||
#[tauri::command]
|
||||
pub async fn close_window(app: AppHandle) -> Result<(), String> {
|
||||
let window = app.get_webview_window("main")
|
||||
.ok_or("Main window not found")?;
|
||||
|
||||
window.close()
|
||||
.map_err(|e| format!("Failed to close window: {}", e))
|
||||
}
|
||||
|
||||
/// Show window (bring to front)
|
||||
#[tauri::command]
|
||||
pub async fn show_window(app: AppHandle) -> Result<(), String> {
|
||||
let window = app.get_webview_window("main")
|
||||
.ok_or("Main window not found")?;
|
||||
|
||||
window.show()
|
||||
.map_err(|e| format!("Failed to show window: {}", e))?;
|
||||
window.set_focus()
|
||||
.map_err(|e| format!("Failed to focus window: {}", e))
|
||||
}
|
||||
|
||||
/// Hide window
|
||||
#[tauri::command]
|
||||
pub async fn hide_window(app: AppHandle) -> Result<(), String> {
|
||||
let window = app.get_webview_window("main")
|
||||
.ok_or("Main window not found")?;
|
||||
|
||||
window.hide()
|
||||
.map_err(|e| format!("Failed to hide window: {}", e))
|
||||
}
|
||||
|
||||
/// Get current window state
|
||||
#[tauri::command]
|
||||
pub async fn get_window_state(app: AppHandle) -> Result<WindowState, String> {
|
||||
let window = app.get_webview_window("main")
|
||||
.ok_or("Main window not found")?;
|
||||
|
||||
let is_always_on_top = window.is_always_on_top()
|
||||
.map_err(|e| format!("Failed to get always on top state: {}", e))?;
|
||||
let is_maximized = window.is_maximized()
|
||||
.map_err(|e| format!("Failed to get maximized state: {}", e))?;
|
||||
let is_minimized = window.is_minimized()
|
||||
.map_err(|e| format!("Failed to get minimized state: {}", e))?;
|
||||
|
||||
Ok(WindowState {
|
||||
is_always_on_top,
|
||||
is_locked: IS_WINDOW_LOCKED.load(Ordering::SeqCst),
|
||||
opacity: 1.0, // Default, actual value tracked in frontend
|
||||
is_maximized,
|
||||
is_minimized,
|
||||
})
|
||||
}
|
||||
|
||||
/// Start window drag (for frameless window)
|
||||
#[tauri::command]
|
||||
pub async fn start_window_drag(app: AppHandle) -> Result<(), String> {
|
||||
let window = app.get_webview_window("main")
|
||||
.ok_or("Main window not found")?;
|
||||
|
||||
window.start_dragging()
|
||||
.map_err(|e| format!("Failed to start window drag: {}", e))
|
||||
}
|
||||
|
||||
/// Move window by delta (for custom drag implementation)
|
||||
#[tauri::command]
|
||||
pub async fn move_window_by(app: AppHandle, delta_x: i32, delta_y: i32) -> Result<(), String> {
|
||||
let window = app.get_webview_window("main")
|
||||
.ok_or("Main window not found")?;
|
||||
|
||||
let position = window.outer_position()
|
||||
.map_err(|e| format!("Failed to get window position: {}", e))?;
|
||||
|
||||
let new_x = position.x + delta_x;
|
||||
let new_y = position.y + delta_y;
|
||||
|
||||
window.set_position(tauri::Position::Physical(tauri::PhysicalPosition::new(new_x, new_y)))
|
||||
.map_err(|e| format!("Failed to move window: {}", e))
|
||||
}
|
||||
166
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
// RefVroid - Animation Reference Desktop App
|
||||
// Tauri backend implementation
|
||||
|
||||
mod commands;
|
||||
|
||||
use commands::{
|
||||
// File operations
|
||||
save_scene,
|
||||
load_scene,
|
||||
read_file_as_base64,
|
||||
show_save_dialog,
|
||||
show_open_dialog,
|
||||
show_open_media_dialog,
|
||||
show_open_images_dialog,
|
||||
get_file_extension,
|
||||
file_exists,
|
||||
get_file_name,
|
||||
// Clipboard operations
|
||||
read_clipboard,
|
||||
write_clipboard_image,
|
||||
write_clipboard_image_base64,
|
||||
write_clipboard_files,
|
||||
write_clipboard_text,
|
||||
write_clipboard_images_as_files,
|
||||
read_image_as_base64,
|
||||
// Window operations
|
||||
set_always_on_top,
|
||||
get_always_on_top,
|
||||
set_window_opacity,
|
||||
lock_window,
|
||||
unlock_window,
|
||||
is_window_locked,
|
||||
is_maximized,
|
||||
toggle_maximize,
|
||||
minimize_window,
|
||||
close_window,
|
||||
show_window,
|
||||
hide_window,
|
||||
get_window_state,
|
||||
start_window_drag,
|
||||
move_window_by,
|
||||
};
|
||||
|
||||
use tauri::{
|
||||
Emitter, Manager,
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
menu::{Menu, MenuItem},
|
||||
};
|
||||
|
||||
fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create menu items
|
||||
let show_item = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?;
|
||||
let unlock_item = MenuItem::with_id(app, "unlock", "解锁窗口", true, None::<&str>)?;
|
||||
let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
|
||||
|
||||
// Create tray menu
|
||||
let menu = Menu::with_items(app, &[&show_item, &unlock_item, &quit_item])?;
|
||||
|
||||
// Build tray icon
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false) // Don't show menu on left click
|
||||
.tooltip("RefVroid - 动画参考工具")
|
||||
.on_menu_event(|app, event| {
|
||||
match event.id.as_ref() {
|
||||
"show" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"unlock" => {
|
||||
// Only unlock if currently locked
|
||||
if commands::window::is_window_locked_state() {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_ignore_cursor_events(false);
|
||||
// Update the global lock state
|
||||
commands::window::set_window_locked_state(false);
|
||||
// Emit event to frontend to update UI
|
||||
let _ = window.emit("window-unlocked", ());
|
||||
}
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
// Toggle window visibility on left click
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.setup(|app| {
|
||||
// Setup system tray
|
||||
setup_tray(app)?;
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// File operations
|
||||
save_scene,
|
||||
load_scene,
|
||||
read_file_as_base64,
|
||||
show_save_dialog,
|
||||
show_open_dialog,
|
||||
show_open_media_dialog,
|
||||
show_open_images_dialog,
|
||||
get_file_extension,
|
||||
file_exists,
|
||||
get_file_name,
|
||||
// Clipboard operations
|
||||
read_clipboard,
|
||||
write_clipboard_image,
|
||||
write_clipboard_image_base64,
|
||||
write_clipboard_files,
|
||||
write_clipboard_text,
|
||||
write_clipboard_images_as_files,
|
||||
read_image_as_base64,
|
||||
// Window operations
|
||||
set_always_on_top,
|
||||
get_always_on_top,
|
||||
set_window_opacity,
|
||||
lock_window,
|
||||
unlock_window,
|
||||
is_window_locked,
|
||||
is_maximized,
|
||||
toggle_maximize,
|
||||
minimize_window,
|
||||
close_window,
|
||||
show_window,
|
||||
hide_window,
|
||||
get_window_state,
|
||||
start_window_drag,
|
||||
move_window_by,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
refvroid_lib::run()
|
||||
}
|
||||
42
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "RefVroid",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.refvroid.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "RefVroid",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"resizable": true,
|
||||
"decorations": false,
|
||||
"transparent": false,
|
||||
"alwaysOnTop": false,
|
||||
"dragDropEnabled": true,
|
||||
"backgroundColor": [25, 25, 25, 255]
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["msi"],
|
||||
"icon": [
|
||||
"icons/icon.png",
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||