feat: add modern GUI interface and cross-platform support
- Add PyQt6-based modern GUI - Add system tray support - Add dark/light theme support - Refactor app into service architecture - Update documentation with GUI features - Add cross-platform compatibility
This commit is contained in:
parent
c0b593958d
commit
f5849d416d
@ -1,69 +1,123 @@
|
|||||||
# 说明
|
# LLMClipboard
|
||||||
Prompt
|
|
||||||
我的目标是:写一个Python程序,可以通过选择任何windows应用的一段富文本,点击鼠标右键后弹出一个选项,点击这个选项可以把富文本保存在配置路径的.markdown文件里,这个markdown会保持选中富文本的图文等相对样式不变可以正常显示。请给出完整的实现步骤和代码,应用.
|
|
||||||
|
|
||||||
# 应用步骤
|
一个跨平台的富文本捕获工具,具有现代化的GUI界面,可以通过选择任何应用程序的富文本内容,快速双击鼠标右键将其保存为markdown格式文件,保持原有的图文样式。
|
||||||
1、激活venv的python环境.venv\Scripts\activate
|
|
||||||
2、配置config.ini文件,配置保存路径
|
|
||||||
3、启动程序,python main.py
|
|
||||||
4、选择需要保存的富文本,右键双击
|
|
||||||
|
|
||||||
# 本地可执行文件打包
|
## 功能特点
|
||||||
|
|
||||||
# 创建虚拟环境
|
- 现代化的图形用户界面
|
||||||
python -m venv .venv
|
- 支持从任何应用程序中捕获富文本
|
||||||
|
- 保持原有的文本格式和样式
|
||||||
|
- 自动转换为markdown格式
|
||||||
|
- 简单的右键双击操作
|
||||||
|
- 可配置的保存路径
|
||||||
|
- 支持HTML、纯文本、Unicode文本格式
|
||||||
|
- 系统托盘支持
|
||||||
|
- 自适应深色/浅色主题
|
||||||
|
- 跨平台支持
|
||||||
|
|
||||||
# 激活虚拟环境
|
## 安装步骤
|
||||||
# 对于 Windows:
|
|
||||||
source .venv/Scripts/activate
|
|
||||||
# 对于 Unix 或 MacOS:
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
1. 项目目录结构:
|
1. 创建虚拟环境:
|
||||||
|
```bash
|
||||||
|
uv venv .venv
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 激活虚拟环境:
|
||||||
|
```bash
|
||||||
|
.venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 安装依赖:
|
||||||
|
```bash
|
||||||
|
.venv\Scripts\python.exe -m pip install pynput pywin32 html2text keyboard configparser PyQt6 darkdetect qt-material
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
编辑 `config.ini` 文件,设置以下参数:
|
||||||
|
```ini
|
||||||
|
[Settings]
|
||||||
|
double_click_threshold = 0.3
|
||||||
|
save_location = 你的保存路径
|
||||||
|
```
|
||||||
|
|
||||||
|
- `double_click_threshold`: 双击判定的时间阈值(秒)
|
||||||
|
- `save_location`: markdown文件的保存路径
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
1. 启动程序:
|
||||||
|
```bash
|
||||||
|
.venv\Scripts\python.exe -m llmclipboard.app
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 使用步骤:
|
||||||
|
- 在GUI界面中设置保存路径和双击阈值
|
||||||
|
- 点击"启动监听"按钮开始监听
|
||||||
|
- 选择需要保存的富文本内容
|
||||||
|
- 快速双击鼠标右键
|
||||||
|
- 文件会自动保存到配置的路径中
|
||||||
|
- 可以最小化到系统托盘继续运行
|
||||||
|
- 按ESC键或点击"停止监听"按钮停止监听
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
llmclipboard/
|
llmclipboard/
|
||||||
├── llmclipboard/
|
├── llmclipboard/
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── app.py
|
│ ├── app.py
|
||||||
|
│ ├── gui.py
|
||||||
├── config.ini
|
├── config.ini
|
||||||
├── README.md
|
├── README.md
|
||||||
├── pyproject.toml
|
├── pyproject.toml
|
||||||
|
```
|
||||||
|
|
||||||
2. pyproject.toml 文件:
|
## 依赖说明
|
||||||
[project]
|
|
||||||
name = "llmclipboard"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "A text capture tool for saving formatted text from clipboard to markdown files."
|
|
||||||
readme = "README.md"
|
|
||||||
requires-python = ">=3.10"
|
|
||||||
dependencies = [
|
|
||||||
"pynput",
|
|
||||||
"pywin32",
|
|
||||||
"html2text",
|
|
||||||
"keyboard",
|
|
||||||
"configparser"
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.scripts]
|
- Python >= 3.10
|
||||||
llmclipboard = "llmclipboard.app:main"
|
- pynput: 鼠标事件监听
|
||||||
|
- pywin32: Windows API交互
|
||||||
|
- html2text: HTML转Markdown转换
|
||||||
|
- keyboard: 键盘事件处理
|
||||||
|
- configparser: 配置文件处理
|
||||||
|
- PyQt6: GUI框架
|
||||||
|
- qt-material: 现代化主题
|
||||||
|
- darkdetect: 系统主题检测
|
||||||
|
|
||||||
3. app.py 文件(确保有 main 函数)
|
## GUI功能
|
||||||
|
|
||||||
4. 安装项目:
|
1. **主要功能**:
|
||||||
# 在编辑模式下安装项目
|
- 可视化配置保存路径和双击阈值
|
||||||
|
- 实时显示程序运行状态
|
||||||
|
- 一键启动/停止监听
|
||||||
|
- 系统托盘支持,最小化后继续运行
|
||||||
|
- 自适应系统主题(深色/浅色)
|
||||||
|
|
||||||
|
2. **系统托盘**:
|
||||||
|
- 双击托盘图标显示主窗口
|
||||||
|
- 右键菜单支持显示/退出操作
|
||||||
|
- 最小化时显示通知消息
|
||||||
|
|
||||||
|
## 开发说明
|
||||||
|
|
||||||
|
1. 安装开发依赖:
|
||||||
|
```bash
|
||||||
uv pip install -e .
|
uv pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
5. 测试入口点:
|
2. 构建分发包:
|
||||||
llmclipboard
|
```bash
|
||||||
|
|
||||||
# 分发构建
|
|
||||||
1. 构建分发包
|
|
||||||
uv pip install build
|
uv pip install build
|
||||||
python -m build
|
python -m build
|
||||||
这将在项目根目录下生成一个 dist 目录,里面包含 .tar.gz 和 .whl 文件,这些就是你的分发包。
|
```
|
||||||
|
|
||||||
2. 其他python环境安装
|
这将在项目根目录下生成 `dist` 目录,包含 `.tar.gz` 和 `.whl` 文件。
|
||||||
pip install dist\llmclipboard-0.1.0.tar.gz
|
|
||||||
pip install dist\llmclipboard-0.1.0-py3-none-any.whl
|
|
||||||
|
|
||||||
3. 程序启动
|
## 许可证
|
||||||
llmclipboard
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## 贡献指南
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request。
|
||||||
@ -1,3 +1,4 @@
|
|||||||
[Settings]
|
[Settings]
|
||||||
double_click_threshold = 0.3
|
double_click_threshold = 0.3
|
||||||
save_location = F:\BaiduSyncdisk\note\NoteForZhukang\000 inbox
|
save_location = F:/BaiduSyncdisk/note/NoteForZhukang/000 inbox
|
||||||
|
|
||||||
|
|||||||
@ -9,20 +9,26 @@ import keyboard
|
|||||||
import threading
|
import threading
|
||||||
import sys
|
import sys
|
||||||
import logging
|
import logging
|
||||||
|
from PyQt6.QtWidgets import QApplication
|
||||||
|
from .gui import MainWindow
|
||||||
|
|
||||||
class TextCaptureApp:
|
class TextCaptureService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.load_config()
|
self.load_config()
|
||||||
self.running = True
|
self.running = True
|
||||||
self.last_right_click_time = 0
|
self.last_right_click_time = 0
|
||||||
self.setup_logging()
|
self.setup_logging()
|
||||||
|
self._mouse_listener = None
|
||||||
|
self._keyboard_listener = None
|
||||||
|
|
||||||
def load_config(self):
|
def load_config(self):
|
||||||
config = configparser.ConfigParser()
|
self.config = configparser.ConfigParser()
|
||||||
config.read('config.ini')
|
config_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.ini')
|
||||||
self.double_click_threshold = config.getfloat('Settings', 'double_click_threshold', fallback=0.3)
|
if os.path.exists(config_file):
|
||||||
self.save_location = config.get('Settings', 'save_location', fallback=os.path.join(os.path.expanduser('~'), 'Desktop'))
|
self.config.read(config_file)
|
||||||
self.copy_delay = config.getfloat('Settings', 'copy_delay', fallback=0.1)
|
self.double_click_threshold = self.config.getfloat('Settings', 'double_click_threshold', fallback=0.3)
|
||||||
|
self.save_location = self.config.get('Settings', 'save_location',
|
||||||
|
fallback=os.path.expanduser('~/Documents'))
|
||||||
|
|
||||||
def setup_logging(self):
|
def setup_logging(self):
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -82,11 +88,11 @@ class TextCaptureApp:
|
|||||||
keyboard.press('c')
|
keyboard.press('c')
|
||||||
keyboard.release('c')
|
keyboard.release('c')
|
||||||
keyboard.release('ctrl')
|
keyboard.release('ctrl')
|
||||||
time.sleep(self.copy_delay) # 等待复制操作完成
|
time.sleep(0.1) # 等待复制操作完成
|
||||||
|
|
||||||
def on_click(self, x, y, button, pressed):
|
def on_click(self, x, y, button, pressed):
|
||||||
if not pressed:
|
if not pressed or not self.running:
|
||||||
return
|
return True
|
||||||
|
|
||||||
if button == mouse.Button.right:
|
if button == mouse.Button.right:
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
@ -98,45 +104,31 @@ class TextCaptureApp:
|
|||||||
else:
|
else:
|
||||||
self.logger.warning("No content captured from clipboard")
|
self.logger.warning("No content captured from clipboard")
|
||||||
self.last_right_click_time = current_time
|
self.last_right_click_time = current_time
|
||||||
|
return True
|
||||||
|
|
||||||
def on_keyboard_event(self, event):
|
def start(self):
|
||||||
# 监听Esc键退出程序
|
self.running = True
|
||||||
if event.name == 'esc':
|
self._mouse_listener = mouse.Listener(on_click=self.on_click)
|
||||||
self.running = False
|
self._mouse_listener.start()
|
||||||
return False
|
self.logger.info("Text capture service started")
|
||||||
|
|
||||||
def run(self):
|
def stop(self):
|
||||||
self.logger.info("Text Capture App started")
|
self.running = False
|
||||||
print("文本捕获程序已启动...")
|
if self._mouse_listener:
|
||||||
print("使用说明:")
|
self._mouse_listener.stop()
|
||||||
print("1. 选中要保存的文本")
|
self._mouse_listener = None
|
||||||
print("2. 快速双击鼠标右键来保存选中的文本")
|
self.logger.info("Text capture service stopped")
|
||||||
print("3. 按ESC键退出程序")
|
|
||||||
|
|
||||||
mouse_listener = mouse.Listener(on_click=self.on_click)
|
|
||||||
mouse_listener.start()
|
|
||||||
|
|
||||||
# 启动键盘监听
|
|
||||||
keyboard.on_press(self.on_keyboard_event)
|
|
||||||
|
|
||||||
# Wait until the running flag becomes False
|
|
||||||
while self.running:
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
print("\n程序已退出")
|
|
||||||
mouse_listener.stop()
|
|
||||||
mouse_listener.join()
|
|
||||||
keyboard_listener.stop()
|
|
||||||
keyboard_listener.join()
|
|
||||||
self.logger.info("Text Capture App stopped")
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
try:
|
# 创建QApplication实例
|
||||||
app = TextCaptureApp()
|
app = QApplication(sys.argv)
|
||||||
app.run()
|
|
||||||
except Exception as e:
|
# 创建主窗口
|
||||||
logging.error(f"程序发生错误: {e}")
|
window = MainWindow()
|
||||||
input("按Enter键退出...")
|
window.show()
|
||||||
|
|
||||||
|
# 运行应用程序
|
||||||
|
sys.exit(app.exec())
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
147
project/llmclipboard/llmclipboard/gui.py
Normal file
147
project/llmclipboard/llmclipboard/gui.py
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
||||||
|
QHBoxLayout, QPushButton, QLabel, QFileDialog,
|
||||||
|
QSystemTrayIcon, QMenu, QSpinBox, QStyle, QLineEdit)
|
||||||
|
from PyQt6.QtCore import Qt, QSettings
|
||||||
|
from PyQt6.QtGui import QIcon, QAction
|
||||||
|
from qt_material import apply_stylesheet
|
||||||
|
import darkdetect
|
||||||
|
from configparser import ConfigParser
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("LLMClipboard")
|
||||||
|
self.setMinimumWidth(500)
|
||||||
|
|
||||||
|
# 初始化服务
|
||||||
|
from .app import TextCaptureService
|
||||||
|
self.service = TextCaptureService()
|
||||||
|
|
||||||
|
# 加载配置
|
||||||
|
self.config = ConfigParser()
|
||||||
|
self.config_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.ini')
|
||||||
|
self.load_config()
|
||||||
|
|
||||||
|
# 创建主窗口部件
|
||||||
|
main_widget = QWidget()
|
||||||
|
self.setCentralWidget(main_widget)
|
||||||
|
layout = QVBoxLayout(main_widget)
|
||||||
|
|
||||||
|
# 保存路径设置
|
||||||
|
save_path_layout = QHBoxLayout()
|
||||||
|
save_path_label = QLabel("保存路径:")
|
||||||
|
self.save_path_edit = QLineEdit(self.config.get('Settings', 'save_location'))
|
||||||
|
browse_button = QPushButton("浏览...")
|
||||||
|
browse_button.clicked.connect(self.browse_save_path)
|
||||||
|
save_path_layout.addWidget(save_path_label)
|
||||||
|
save_path_layout.addWidget(self.save_path_edit)
|
||||||
|
save_path_layout.addWidget(browse_button)
|
||||||
|
layout.addLayout(save_path_layout)
|
||||||
|
|
||||||
|
# 双击阈值设置
|
||||||
|
threshold_layout = QHBoxLayout()
|
||||||
|
threshold_label = QLabel("双击阈值 (秒):")
|
||||||
|
self.threshold_spin = QSpinBox()
|
||||||
|
self.threshold_spin.setRange(1, 1000)
|
||||||
|
self.threshold_spin.setValue(int(float(self.config.get('Settings', 'double_click_threshold')) * 1000))
|
||||||
|
self.threshold_spin.setSingleStep(100)
|
||||||
|
threshold_layout.addWidget(threshold_label)
|
||||||
|
threshold_layout.addWidget(self.threshold_spin)
|
||||||
|
layout.addLayout(threshold_layout)
|
||||||
|
|
||||||
|
# 状态显示
|
||||||
|
self.status_label = QLabel("状态: 已停止")
|
||||||
|
layout.addWidget(self.status_label)
|
||||||
|
|
||||||
|
# 控制按钮
|
||||||
|
button_layout = QHBoxLayout()
|
||||||
|
self.start_button = QPushButton("启动监听")
|
||||||
|
self.start_button.clicked.connect(self.toggle_monitoring)
|
||||||
|
self.save_button = QPushButton("保存设置")
|
||||||
|
self.save_button.clicked.connect(self.save_config)
|
||||||
|
button_layout.addWidget(self.start_button)
|
||||||
|
button_layout.addWidget(self.save_button)
|
||||||
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
|
# 系统托盘
|
||||||
|
self.setup_system_tray()
|
||||||
|
|
||||||
|
# 设置样式
|
||||||
|
self.setup_style()
|
||||||
|
|
||||||
|
def load_config(self):
|
||||||
|
if os.path.exists(self.config_file):
|
||||||
|
self.config.read(self.config_file)
|
||||||
|
else:
|
||||||
|
self.config['Settings'] = {
|
||||||
|
'save_location': os.path.expanduser('~/Documents'),
|
||||||
|
'double_click_threshold': '0.3'
|
||||||
|
}
|
||||||
|
self.save_config()
|
||||||
|
|
||||||
|
def save_config(self):
|
||||||
|
self.config['Settings']['save_location'] = self.save_path_edit.text()
|
||||||
|
self.config['Settings']['double_click_threshold'] = str(self.threshold_spin.value() / 1000)
|
||||||
|
|
||||||
|
with open(self.config_file, 'w') as f:
|
||||||
|
self.config.write(f)
|
||||||
|
|
||||||
|
def browse_save_path(self):
|
||||||
|
folder = QFileDialog.getExistingDirectory(self, "选择保存路径", self.save_path_edit.text())
|
||||||
|
if folder:
|
||||||
|
self.save_path_edit.setText(folder)
|
||||||
|
|
||||||
|
def toggle_monitoring(self):
|
||||||
|
if self.start_button.text() == "启动监听":
|
||||||
|
self.start_button.setText("停止监听")
|
||||||
|
self.status_label.setText("状态: 正在运行")
|
||||||
|
self.save_config()
|
||||||
|
self.service.start()
|
||||||
|
else:
|
||||||
|
self.start_button.setText("启动监听")
|
||||||
|
self.status_label.setText("状态: 已停止")
|
||||||
|
self.service.stop()
|
||||||
|
|
||||||
|
def setup_system_tray(self):
|
||||||
|
self.tray_icon = QSystemTrayIcon(self)
|
||||||
|
self.tray_icon.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon))
|
||||||
|
|
||||||
|
# 创建托盘菜单
|
||||||
|
tray_menu = QMenu()
|
||||||
|
show_action = QAction("显示", self)
|
||||||
|
quit_action = QAction("退出", self)
|
||||||
|
show_action.triggered.connect(self.show)
|
||||||
|
quit_action.triggered.connect(self.close)
|
||||||
|
tray_menu.addAction(show_action)
|
||||||
|
tray_menu.addAction(quit_action)
|
||||||
|
|
||||||
|
self.tray_icon.setContextMenu(tray_menu)
|
||||||
|
self.tray_icon.show()
|
||||||
|
|
||||||
|
# 双击托盘图标显示主窗口
|
||||||
|
self.tray_icon.activated.connect(self.tray_icon_activated)
|
||||||
|
|
||||||
|
def tray_icon_activated(self, reason):
|
||||||
|
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
|
||||||
|
self.show()
|
||||||
|
|
||||||
|
def setup_style(self):
|
||||||
|
# 根据系统主题设置深色/浅色模式
|
||||||
|
if darkdetect.isDark():
|
||||||
|
apply_stylesheet(self, theme='dark_blue.xml')
|
||||||
|
else:
|
||||||
|
apply_stylesheet(self, theme='light_blue.xml')
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
if self.start_button.text() == "停止监听":
|
||||||
|
self.service.stop()
|
||||||
|
event.ignore()
|
||||||
|
self.hide()
|
||||||
|
self.tray_icon.showMessage(
|
||||||
|
"LLMClipboard",
|
||||||
|
"应用程序已最小化到系统托盘",
|
||||||
|
QSystemTrayIcon.Icon.Information,
|
||||||
|
2000
|
||||||
|
)
|
||||||
@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "llmclipboard"
|
name = "llmclipboard"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "A text capture tool for saving formatted text from clipboard to markdown files."
|
description = "A cross-platform rich text capture tool with GUI support"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@ -9,7 +9,10 @@ dependencies = [
|
|||||||
"pywin32",
|
"pywin32",
|
||||||
"html2text",
|
"html2text",
|
||||||
"keyboard",
|
"keyboard",
|
||||||
"configparser"
|
"configparser",
|
||||||
|
"PyQt6",
|
||||||
|
"darkdetect",
|
||||||
|
"qt-material"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user