一、项目概述

本项目实现一个 Windows 平台剪贴板历史工具。

程序在后台运行,自动监听系统剪贴板变化(复制文本/图片路径),并将历史内容保存到环形缓冲区。

用户可以通过快捷键 Ctrl + Shift + V 打开终端界面(TUI)浏览、搜索并一键粘贴。


二、项目功能

功能 说明
实时监听 监控剪贴板变化(文本/图片路径)
自动保存 历史保存在环形缓冲区
快捷键呼出 Ctrl + Shift + V 打开终端界面
TUI 浏览 可滚动查看历史,支持自动刷新
一键粘贴 选中项直接粘贴到当前焦点窗口
后台运行 长期监听,资源占用极低

三、项目结构

cliphist/

├── Cargo.toml

└── src/

├── main.rs

├── storage.rs

├── platform/

│ ├── mod.rs

│ └── windows.rs

├── paste.rs

├── tui_app.rs

└── utils.rs



四、Cargo.toml

[package]
name = "cliphist"
version = "0.1.0"
edition = "2021"

[dependencies]
winapi = { version = "0.3", features = ["winuser", "windowsx", "wingdi", "shellapi", "libloaderapi", "winbase"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dirs = "4.0"
tui = "0.19"
crossterm = "0.26"
rdev = "0.5"
chrono = "0.4"
uuid = { version = "1", features = ["v4"] }

五、完整源码

src/main.rs

mod platform;
mod storage;
mod tui_app;
mod paste;
mod utils;

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use storage::RingBuffer;

fn main() {let store = Arc::new(Mutex::new(RingBuffer::new(200)));
// 启动剪贴板监听线程
    platform::windows::start_clipboard_listener(store.clone());
// 注册全局热键 Ctrl + Shift + V 打开 TUI
    platform::windows::register_hotkey(move || {
        tui_app::show_tui(store.clone());
    });
println!("📋 ClipHist running... (Ctrl+Shift+V to open panel)");
// 主线程挂起(后台常驻)
    loop {
        thread::sleep(Duration::from_secs(60));
    }
}

src/storage.rs

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Clone)]
pub enum ClipContent {Text(String),Image(String),
}

#[derive(Serialize, Deserialize, Clone)]
pub struct ClipItem {pub id: String,pub timestamp: i64,pub content: ClipContent,
}

pub struct RingBuffer {
    capacity: usize,
    items: Vec<ClipItem>,
}

impl RingBuffer {pub fn new(capacity: usize) -> Self {Self { capacity, items: Vec::new() }
    }
pub fn push(&mut self, item: ClipItem) {if self.items.len() >= self.capacity {self.items.remove(0);
        }self.items.push(item);
    }
pub fn all(&self) -> &Vec<ClipItem> {
        &self.items
    }
}

src/platform/mod.rs

#[cfg(target_os = "windows")]
pub mod windows;

src/platform/windows.rs

use std::sync::{Arc, Mutex};
use winapi::shared::windef::HWND;
use winapi::shared::minwindef::{LPARAM, LRESULT, UINT, WPARAM};
use winapi::um::winuser::*;
use winapi::um::winbase::{GlobalLock, GlobalUnlock, GlobalAlloc, GlobalFree, GMEM_MOVEABLE};
use winapi::um::libloaderapi::GetModuleHandleW;
use crate::storage::{ClipItem, ClipContent, RingBuffer};
use uuid::Uuid;
use chrono::Utc;
use std::ptr;

pub fn start_clipboard_listener(store: Arc<Mutex<RingBuffer>>) {
    std::thread::spawn(move || unsafe {let h_instance = GetModuleHandleW(ptr::null());let class_name = wide_str("ClipHistWnd");
let wc = WNDCLASSW {
            lpfnWndProc: Some(window_proc),
            hInstance: h_instance,
            lpszClassName: class_name.as_ptr(),
            ..std::mem::zeroed()
        };
RegisterClassW(&wc);
let hwnd = CreateWindowExW(0,
            class_name.as_ptr(),wide_str("ClipHist").as_ptr(),0,0, 0, 0, 0,
            ptr::null_mut(),
            ptr::null_mut(),
            h_instance,
            ptr::null_mut(),
        );
SetWindowLongPtrW(hwnd, GWLP_USERDATA, Box::into_raw(Box::new(store)) as isize);AddClipboardFormatListener(hwnd);
let mut msg = std::mem::zeroed();while GetMessageW(&mut msg, hwnd, 0, 0) > 0 {TranslateMessage(&msg);DispatchMessageW(&msg);
        }
    });
}

unsafe extern "system" fn window_proc(hwnd: HWND, msg: UINT, _wparam: WPARAM, _lparam: LPARAM) -> LRESULT {match msg {
        WM_CLIPBOARDUPDATE => {let ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut Arc<Mutex<RingBuffer>>;if !ptr.is_null() {let store = &*ptr;if let Some(item) = read_clipboard() {
                    store.lock().unwrap().push(item);
                }
            }0
        }
        _ => DefWindowProcW(hwnd, msg, _wparam, _lparam),
    }
}

unsafe fn read_clipboard() -> Option<ClipItem> {if OpenClipboard(ptr::null_mut()) == 0 {return None;
    }
let handle = GetClipboardData(CF_UNICODETEXT);if handle.is_null() {CloseClipboard();return None;
    }
let ptr_text = GlobalLock(handle) as *const u16;if ptr_text.is_null() {CloseClipboard();return None;
    }
let text = String::from_utf16_lossy(&u16_ptr_to_vec(ptr_text));GlobalUnlock(handle);CloseClipboard();
Some(ClipItem {
        id: Uuid::new_v4().to_string(),
        timestamp: Utc::now().timestamp(),
        content: ClipContent::Text(text),
    })
}

unsafe fn u16_ptr_to_vec(ptr: *const u16) -> Vec<u16> {let mut v = Vec::new();let mut i = 0;while *ptr.add(i) != 0 {
        v.push(*ptr.add(i));
        i += 1;
    }
    v
}

fn wide_str(s: &str) -> Vec<u16> {use std::os::windows::ffi::OsStrExt;
    std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect()
}

pub fn set_clipboard_text(text: &str) {unsafe {if OpenClipboard(ptr::null_mut()) == 0 {return;
        }EmptyClipboard();
let wide: Vec<u16> = text.encode_utf16().chain(std::iter::once(0)).collect();let size = wide.len() * std::mem::size_of::<u16>();
let h_mem = GlobalAlloc(GMEM_MOVEABLE, size);if h_mem.is_null() {CloseClipboard();return;
        }
let ptr_mem = GlobalLock(h_mem) as *mut u16;if ptr_mem.is_null() {GlobalFree(h_mem);CloseClipboard();return;
        }
        std::ptr::copy_nonoverlapping(wide.as_ptr(), ptr_mem, wide.len());GlobalUnlock(h_mem);SetClipboardData(CF_UNICODETEXT, h_mem);CloseClipboard();
    }
}

pub fn register_hotkey(callback: impl Fn() + Send + 'static) {
    std::thread::spawn(move || unsafe {let hotkey_id: i32 = 1;RegisterHotKey(ptr::null_mut(), hotkey_id, (MOD_CONTROL | MOD_SHIFT) as u32, 0x56); // 'V'

        let mut msg = std::mem::zeroed();while GetMessageW(&mut msg, ptr::null_mut(), 0, 0) > 0 {if msg.message == WM_HOTKEY {callback();
            }
        }
    });
}

src/paste.rs

use crate::storage::ClipItem;
use rdev::{simulate, Key, SimulateError};

pub fn simulate_paste(item: &ClipItem) {if let crate::storage::ClipContent::Text(text) = &item.content {
        crate::platform::windows::set_clipboard_text(text);let _ = paste_combo();
    }
}

fn paste_combo() -> Result<(), SimulateError> {simulate(&rdev::EventType::KeyPress(Key::ControlLeft))?;simulate(&rdev::EventType::KeyPress(Key::KeyV))?;simulate(&rdev::EventType::KeyRelease(Key::KeyV))?;simulate(&rdev::EventType::KeyRelease(Key::ControlLeft))?;Ok(())
}

src/tui_app.rs

use crate::storage::{RingBuffer, ClipContent};
use crossterm::{
    event::{self, Event, KeyCode},
    terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType},
    execute,
};
use tui::{
    backend::CrosstermBackend,
    Terminal,
    widgets::{Block, Borders, List, ListItem},
    layout::{Layout, Constraint, Direction},
};
use std::{
    io::{self, stdout},
    sync::{Arc, Mutex},
    time::{Duration, Instant},
};
use crate::paste::simulate_paste;

pub fn show_tui(store: Arc<Mutex<RingBuffer>>) {
    execute!(stdout(), Clear(ClearType::All)).unwrap();enable_raw_mode().unwrap();let mut stdout = io::stdout();let backend = CrosstermBackend::new(&mut stdout);let mut terminal = Terminal::new(backend).unwrap();
let mut index = 0;let mut last_draw = Instant::now();
loop {if last_draw.elapsed() >= Duration::from_millis(500) {let items = store.lock().unwrap().all().clone();
            terminal
                .draw(|f| {let chunks = Layout::default()
                        .direction(Direction::Vertical)
                        .constraints([Constraint::Percentage(100)].as_ref())
                        .split(f.size());
let list_items: Vec<ListItem> = items
                        .iter()
                        .rev()
                        .enumerate()
                        .map(|(i, it)| {let content = match &it.content {
                                ClipContent::Text(t) => t.clone(),
                                ClipContent::Image(p) => format!("[Image] {}", p),
                            };
                            ListItem::new(format!("{}: {}", i, content))
                        })
                        .collect();
let list = List::new(list_items)
                        .block(Block::default().title("📋 Clipboard History (Auto Refresh)").borders(Borders::ALL));
                    f.render_widget(list, chunks[0]);
                })
                .unwrap();
            last_draw = Instant::now();
        }
if event::poll(Duration::from_millis(100)).unwrap() {if let Event::Key(key) = event::read().unwrap() {match key.code {
                    KeyCode::Esc => break,
                    KeyCode::Up => if index > 0 { index -= 1; },
                    KeyCode::Down => index += 1,
                    KeyCode::Enter => {let items = store.lock().unwrap().all().clone();if let Some(item) = items.iter().rev().nth(index).cloned() {simulate_paste(&item);
                        }break;
                    }
                    _ => {}
                }
            }
        }
    }
disable_raw_mode().unwrap();
}

src/utils.rs

use std::path::PathBuf;
use dirs::data_local_dir;

pub fn app_data_dir() -> PathBuf {let mut dir = data_local_dir().unwrap_or(std::env::temp_dir());
    dir.push("cliphist");
    std::fs::create_dir_all(&dir).ok();
    dir
}

六、运行

cargo run --quiet

运行后:

  1. 复制任意文字;
  2. Ctrl + Shift + V
  3. 面板实时显示剪贴板历史;

支持实时刷新,每个一段时间刷新面板

七、效果演示

Clipboard History (Auto Refresh)
───────────────────────────────────────────────
0: Hello, world!
1: cargo run --quiet
2: Rust clipboard test
───────────────────────────────────────────────

用 Rust 打造跨平台、高性能的系统剪贴板历史工具,让「复制不丢失、粘贴更自由」。

想了解更多关于Rust语言的知识及应用,可前往华为开放原子旋武开源社区(https://xuanwu.openatom.cn/),了解更多资讯~

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐