feat: 初始化项目基础架构和核心功能

添加项目基础文件结构,包括Tauri配置、前端框架和核心功能模块
实现WebGPU渲染器基础、纹理管理和着色器
添加UI组件包括标题栏、确认对话框、帮助窗口等
设置状态管理存储和工具函数
配置构建工具和开发环境
This commit is contained in:
2025-12-19 16:22:27 +08:00
commit f5b8a563cb
73 changed files with 21514 additions and 0 deletions

7
src-tauri/.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

41
src-tauri/Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

View 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))
}

View 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())
}

View 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,
};

View 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
View 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
View 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
View 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"
]
}
}