commit 6418a9fa7851788dee4b2bbe34a4f472139f70bd Author: easyz Date: Sun Mar 22 18:04:20 2026 +0800 feat: initial public release TZshot diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f5d7d02 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,25 @@ +root = true + +[*.{cpp,h,c,qml,ts,md,txt,cmake}] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[CMakeLists.txt] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.yml] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ddc538 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.bat text eol=crlf +*.ps1 text eol=crlf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.svg text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe6afa0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,106 @@ +# This file is used to ignore files which are generated +# ---------------------------------------------------------------------------- + +*~ +*.autosave +*.a +*.core +*.moc +*.o +*.obj +*.orig +*.rej +*.so +*.so.* +*_pch.h.cpp +*_resource.rc +*.qm +.#* +*.*# +core +!core/ +tags +.DS_Store +.directory +*.debug +Makefile* +*.prl +*.app +moc_*.cpp +ui_*.h +qrc_*.cpp +Thumbs.db +*.res +*.rc +/.qmake.cache +/.qmake.stash + +# qtcreator generated files +*.pro.user* +CMakeLists.txt.user +CMakeLists.txt.user.* +cmake-build-*/ + +# xemacs temporary files +*.flc + +# Vim temporary files +.*.swp + +# Visual Studio generated files +*.ib_pdb_index +*.idb +*.ilk +*.pdb +*.sln +*.suo +*.vcproj +*vcproj.*.*.user +*.ncb +*.sdf +*.opensdf +*.vcxproj +*vcxproj.* + +# MinGW generated files +*.Debug +*.Release + +# Python byte code +*.pyc + +# Binaries +# -------- +*.dll +*.exe + +# Visual Studio / CMake build artifacts +# ------------------------------------ +.vs/ +ipch/ +Debug/ +Release/ +x64/ +x86/ +[Bb]uild/ +[Bb]uild*/ +CMakeFiles/ +CMakeCache.txt +cmake_install.cmake +compile_commands.json +*.dir/ +*.tlog +*.lastbuildstate +*.vcxproj.user +*.cache +*.ilk +*.pdb +*.exp +*.lib +*.obj +*.idb + +# Third-party packages (do not commit vendor/downloaded dependencies) +# ------------------------------------------------------------------ +thirdpart/** +!thirdpart/.gitkeep diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..e373b33 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,425 @@ +cmake_minimum_required(VERSION 3.16) + +project(TZshot VERSION 1.0 LANGUAGES CXX C) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_AUTOUIC ON) + +include(ExternalProject) + +find_package(Qt6 REQUIRED COMPONENTS + Core + Gui + Widgets + Quick + Qml + Network + Concurrent + LinguistTools + QuickDialogs2 +) + +qt_standard_project_setup() + +# ── Third-party build options ───────────────────────────────── +option(BUILD_THIRDPART_OCR "Auto build OCR libs" ON) +option(OCR_FETCH_FROM_GITHUB "Fetch Leptonica/Tesseract from GitHub before building" ON) + +# ── 翻译文件 ────────────────────────────────────────────── +set(TS_FILES + i18n/app_zh_CN.ts + i18n/app_en.ts +) + +# ── 平台无关源文件 ──────────────────────────────────────── +set(SOURCES + src/main.cpp + src/tray_icon_helper.cpp + src/tray_icon_helper.h + src/language_manager.cpp + src/language_manager.h + src/magnifier_provider.cpp + src/magnifier_provider.h + src/sticky_image_store.cpp + src/sticky_image_store.h + src/sticky_image_provider_proxy.cpp + src/sticky_image_provider_proxy.h + src/singleton.h + + # Model + src/model/desktop_snapshot.cpp + src/model/desktop_snapshot.h + src/model/app_settings.cpp + src/model/app_settings.h + + # ViewModel + src/viewmodel/screenshot_view_model.cpp + src/viewmodel/screenshot_view_model.h + src/viewmodel/sticky_view_model.cpp + src/viewmodel/sticky_view_model.h + src/viewmodel/ai_view_model.cpp + src/viewmodel/ai_view_model.h + src/viewmodel/storage_view_model.cpp + src/viewmodel/storage_view_model.h + src/viewmodel/gif_record_view_model.cpp + src/viewmodel/gif_record_view_model.h + src/viewmodel/ocr_view_model.cpp + src/viewmodel/ocr_view_model.h + + # GIF Encoder + src/gif_encoder/gif.h + src/gif_encoder/gif_encoder.cpp + src/gif_encoder/gif_encoder.h + src/gif_encoder/gif_frame_grabber.cpp + src/gif_encoder/gif_frame_grabber.h + + # OCR + src/ocr/ocr_engine.cpp + src/ocr/ocr_engine.h + + # PaintBoard + src/paint_board/paint_board.cpp + src/paint_board/paint_board.h + src/paint_board/shape/shape.cpp + src/paint_board/shape/shape.h + src/paint_board/shape/shape_type.h + src/paint_board/shape/shape_factory.cpp + src/paint_board/shape/shape_factory.h + src/paint_board/shape/two_point_shape.cpp + src/paint_board/shape/two_point_shape.h + src/paint_board/shape/arrow_shape.cpp + src/paint_board/shape/arrow_shape.h + src/paint_board/shape/ellipse_shape.cpp + src/paint_board/shape/ellipse_shape.h + src/paint_board/shape/mosaic_shape.cpp + src/paint_board/shape/mosaic_shape.h + src/paint_board/shape/pen_shape.cpp + src/paint_board/shape/pen_shape.h + src/paint_board/shape/rect_shape.cpp + src/paint_board/shape/rect_shape.h + src/paint_board/shape/text_shape.cpp + src/paint_board/shape/text_shape.h + src/paint_board/shape/number_shape.cpp + src/paint_board/shape/number_shape.h + + # AI + src/ai_call/ai_call_base.cpp + src/ai_call/ai_call_base.h + src/ai_call/ai_type.h + src/ai_call/qwen_ai_call.cpp + src/ai_call/qwen_ai_call.h + src/ai_call/seedream_ai_call.cpp + src/ai_call/seedream_ai_call.h + + # Shortcut — 跨平台公共部分 + src/shortcut_key/globalshortcut.cpp + src/shortcut_key/globalshortcut.h + + # Resources + qml.qrc + i18n/i18n.qrc +) + +# ── 平台专属源文件 ──────────────────────────────────────── +if(UNIX) + list(APPEND SOURCES + src/shortcut_key/linux_grab.c + src/shortcut_key/linux_grab.h + ) +endif() + +if(WIN32) + list(APPEND SOURCES + src/shortcut_key/win_grab.c + src/shortcut_key/win_grab.h + ) +endif() + +qt_add_executable(TZshot + ${SOURCES} +) + +# ── include 路径 ────────────────────────────────────────── +target_include_directories(TZshot PRIVATE + src + src/ai_call + src/gif_encoder + src/ocr + src/paint_board + src/paint_board/shape + src/model + src/viewmodel + src/shortcut_key +) + +# ── 编译定义 ────────────────────────────────────────────── +target_compile_definitions(TZshot PRIVATE + QT_DEPRECATED_WARNINGS +) + +target_compile_definitions(TZshot PRIVATE + $<$:STICKY_MEM_DEBUG> +) + +# ── Qt 模块链接 ─────────────────────────────────────────── +target_link_libraries(TZshot PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Widgets + Qt6::Quick + Qt6::Qml + Qt6::Network + Qt6::Concurrent + Qt6::QuickDialogs2 +) + +# ── 平台系统库 ──────────────────────────────────────────── +if(UNIX) + target_link_libraries(TZshot PRIVATE + X11 + xcb + pthread + ) + + set(OCR_ENABLED FALSE) + set(OCR_PREFIX "$ENV{OCR_PREFIX}") + if(OCR_PREFIX STREQUAL "") + set(OCR_PREFIX "${CMAKE_CURRENT_SOURCE_DIR}/thirdpart/ocr") + endif() + + set(OCR_LIB_DIR "") + if(EXISTS "${OCR_PREFIX}/lib/libtesseract.so" OR EXISTS "${OCR_PREFIX}/lib/libtesseract.a") + set(OCR_LIB_DIR "${OCR_PREFIX}/lib") + elseif(EXISTS "${OCR_PREFIX}/lib64/libtesseract.so" OR EXISTS "${OCR_PREFIX}/lib64/libtesseract.a") + set(OCR_LIB_DIR "${OCR_PREFIX}/lib64") + endif() + + if(NOT OCR_LIB_DIR STREQUAL "") + target_include_directories(TZshot PRIVATE + "${OCR_PREFIX}/include" + "${OCR_PREFIX}/include/leptonica" + ) + target_link_directories(TZshot PRIVATE "${OCR_LIB_DIR}") + target_link_libraries(TZshot PRIVATE tesseract leptonica) + target_compile_definitions(TZshot PRIVATE + ENABLE_OCR + OCR_TESSDATA_DIR=\"${OCR_PREFIX}/share/tessdata\" + ) + set(OCR_ENABLED TRUE) + message(STATUS "OCR enabled via thirdpart: ${OCR_PREFIX}") + endif() + + if(NOT OCR_ENABLED) + find_package(PkgConfig QUIET) + if(PkgConfig_FOUND) + pkg_check_modules(TESSERACT QUIET IMPORTED_TARGET tesseract) + pkg_check_modules(LEPT QUIET IMPORTED_TARGET lept) + if(TESSERACT_FOUND AND LEPT_FOUND) + target_link_libraries(TZshot PRIVATE PkgConfig::TESSERACT PkgConfig::LEPT) + target_compile_definitions(TZshot PRIVATE ENABLE_OCR) + message(STATUS "OCR enabled via pkg-config: tesseract + lept") + else() + message(STATUS "OCR disabled: thirdpart OCR not found and pkg-config tesseract/lept unavailable") + endif() + else() + message(STATUS "OCR disabled: PkgConfig not found") + endif() + endif() +endif() + +if(WIN32) + target_link_libraries(TZshot PRIVATE + user32 # RegisterHotKey / UnregisterHotKey / EnumWindows + gdi32 + ) + + set(OCR_ENABLED FALSE) + if(BUILD_THIRDPART_OCR AND OCR_FETCH_FROM_GITHUB) + set(OCR_THIRDPART_DIR "${CMAKE_CURRENT_SOURCE_DIR}/thirdpart") + set(OCR_INSTALL_DIR "${OCR_THIRDPART_DIR}/ocr-install") + set(OCR_TESSDATA_DIR "${OCR_INSTALL_DIR}/share/tessdata") + set(OCR_EP_REV "v2") + set(OCR_ZLIB_VERSION "v1.3.1") + set(OCR_LIBPNG_VERSION "v1.6.43") + set(OCR_LIBJPEG_TURBO_VERSION "3.0.3") + set(OCR_LIBTIFF_VERSION "v4.7.0") + set(OCR_LEPTONICA_VERSION "1.87.0") + set(OCR_TESSERACT_VERSION "5.5.2") + # Imported target include dirs are validated at generate time. + # Create expected install dirs up front; actual contents are produced by ExternalProject. + file(MAKE_DIRECTORY + "${OCR_INSTALL_DIR}/include" + "${OCR_INSTALL_DIR}/lib" + "${OCR_TESSDATA_DIR}" + ) + + ExternalProject_Add(zlib_ep + PREFIX "${OCR_THIRDPART_DIR}/zlib-${OCR_EP_REV}" + GIT_REPOSITORY "https://github.com/madler/zlib.git" + GIT_TAG "${OCR_ZLIB_VERSION}" + UPDATE_DISCONNECTED 1 + CMAKE_ARGS + -DCMAKE_INSTALL_PREFIX=${OCR_INSTALL_DIR} + -DBUILD_SHARED_LIBS=ON + -DCMAKE_POSITION_INDEPENDENT_CODE=ON + ) + + ExternalProject_Add(libpng_ep + PREFIX "${OCR_THIRDPART_DIR}/libpng-${OCR_EP_REV}" + GIT_REPOSITORY "https://github.com/pnggroup/libpng.git" + GIT_TAG "${OCR_LIBPNG_VERSION}" + UPDATE_DISCONNECTED 1 + DEPENDS zlib_ep + CMAKE_ARGS + -DCMAKE_INSTALL_PREFIX=${OCR_INSTALL_DIR} + -DCMAKE_PREFIX_PATH=${OCR_INSTALL_DIR} + -DZLIB_ROOT=${OCR_INSTALL_DIR} + -DPNG_SHARED=ON + -DPNG_TESTS=OFF + -DPNG_EXECUTABLES=OFF + -DPNG_FRAMEWORK=OFF + ) + + ExternalProject_Add(libjpeg_ep + PREFIX "${OCR_THIRDPART_DIR}/libjpeg-${OCR_EP_REV}" + GIT_REPOSITORY "https://github.com/libjpeg-turbo/libjpeg-turbo.git" + GIT_TAG "${OCR_LIBJPEG_TURBO_VERSION}" + UPDATE_DISCONNECTED 1 + CMAKE_ARGS + -DCMAKE_INSTALL_PREFIX=${OCR_INSTALL_DIR} + -DBUILD_SHARED_LIBS=ON + -DENABLE_SHARED=ON + -DENABLE_STATIC=OFF + -DWITH_TURBOJPEG=ON + -DWITH_JPEG8=ON + ) + + ExternalProject_Add(libtiff_ep + PREFIX "${OCR_THIRDPART_DIR}/libtiff-${OCR_EP_REV}" + GIT_REPOSITORY "https://github.com/libsdl-org/libtiff.git" + GIT_TAG "${OCR_LIBTIFF_VERSION}" + UPDATE_DISCONNECTED 1 + DEPENDS zlib_ep + CMAKE_ARGS + -DCMAKE_INSTALL_PREFIX=${OCR_INSTALL_DIR} + -DCMAKE_PREFIX_PATH=${OCR_INSTALL_DIR} + -DBUILD_SHARED_LIBS=ON + -Dzlib=ON + -Djpeg=OFF + -Dwebp=OFF + -Dzstd=OFF + -Dlzma=OFF + -Djbig=OFF + -Dlerc=OFF + -Dtiff-tools=OFF + -Dtiff-tests=OFF + -Dtiff-contrib=OFF + -Dtiff-docs=OFF + ) + + ExternalProject_Add(leptonica_ep + PREFIX "${OCR_THIRDPART_DIR}/leptonica-${OCR_EP_REV}" + GIT_REPOSITORY "https://github.com/DanBloomberg/leptonica.git" + GIT_TAG "${OCR_LEPTONICA_VERSION}" + UPDATE_DISCONNECTED 1 + DEPENDS libpng_ep libjpeg_ep libtiff_ep + CMAKE_ARGS + -DCMAKE_INSTALL_PREFIX=${OCR_INSTALL_DIR} + -DCMAKE_PREFIX_PATH=${OCR_INSTALL_DIR} + -DBUILD_SHARED_LIBS=ON + -DCMAKE_POSITION_INDEPENDENT_CODE=ON + -DSW_BUILD=OFF + -DSTRICT_CONF=ON + -DTIFF_DIR=${OCR_INSTALL_DIR}/lib/cmake/tiff + -DTIFF_INCLUDE_DIR=${OCR_INSTALL_DIR}/include + -DTIFF_LIBRARY=${OCR_INSTALL_DIR}/lib/tiffd.lib + -DENABLE_ZLIB=ON + -DENABLE_PNG=ON + -DENABLE_GIF=OFF + -DENABLE_JPEG=ON + -DENABLE_TIFF=ON + -DENABLE_WEBP=OFF + -DENABLE_OPENJPEG=OFF + ) + + ExternalProject_Add(tesseract_ep + PREFIX "${OCR_THIRDPART_DIR}/tesseract-${OCR_EP_REV}" + GIT_REPOSITORY "https://github.com/tesseract-ocr/tesseract.git" + GIT_TAG "${OCR_TESSERACT_VERSION}" + UPDATE_DISCONNECTED 1 + DEPENDS leptonica_ep + CMAKE_ARGS + -DCMAKE_INSTALL_PREFIX=${OCR_INSTALL_DIR} + -DCMAKE_PREFIX_PATH=${OCR_INSTALL_DIR} + -DBUILD_SHARED_LIBS=ON + -DSW_BUILD=OFF + -DBUILD_TRAINING_TOOLS=OFF + -DBUILD_TESTS=OFF + -DDISABLE_ARCHIVE=ON + -DDISABLE_CURL=ON + -DOPENMP_BUILD=OFF + ) + + add_dependencies(TZshot tesseract_ep) + + target_include_directories(TZshot PRIVATE "${OCR_INSTALL_DIR}/include") + target_link_directories(TZshot PRIVATE "${OCR_INSTALL_DIR}/lib") + target_link_libraries(TZshot PRIVATE + "tesseract55$<$:d>" + "leptonica-${OCR_LEPTONICA_VERSION}$<$:d>" + Ws2_32 + ) + + target_compile_definitions(TZshot PRIVATE + ENABLE_OCR + OCR_TESSDATA_DIR=\"${OCR_TESSDATA_DIR}\" + ) + + # Ensure runtime OCR DLLs are available beside TZshot.exe. + add_custom_command(TARGET TZshot POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_directory + "${OCR_INSTALL_DIR}/bin" + "$" + VERBATIM + ) + + set(OCR_ENABLED TRUE) + message(STATUS "OCR enabled via GitHub fetch/build (Leptonica ${OCR_LEPTONICA_VERSION}, Tesseract ${OCR_TESSERACT_VERSION})") + endif() + + if(NOT OCR_ENABLED) + message(STATUS "OCR disabled on Windows: BUILD_THIRDPART_OCR/OCR_FETCH_FROM_GITHUB is OFF") + endif() + + # Windows 子系统:Release 用 /SUBSYSTEM:WINDOWS 隐藏控制台窗口 + set_target_properties(TZshot PROPERTIES + WIN32_EXECUTABLE $,FALSE,TRUE> + ) +endif() + +# ── QML 导入路径 ────────────────────────────────────────── +set_target_properties(TZshot PROPERTIES + QT_QML_IMPORT_PATH "${CMAKE_CURRENT_SOURCE_DIR}/qml" +) + +# ── 翻译 ────────────────────────────────────────────────── +qt_add_translations(TZshot + TS_FILES ${TS_FILES} +) + +# ── 安装 ────────────────────────────────────────────────── +if(UNIX AND NOT ANDROID) + install(TARGETS TZshot + DESTINATION /opt/TZshot/bin + ) +endif() + +if(WIN32) + install(TARGETS TZshot + DESTINATION bin + ) +endif() diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..99938ff --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +SPDX-License-Identifier: GPL-3.0-only + +Copyright (C) 2025 TZshot contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Full license text: +https://www.gnu.org/licenses/gpl-3.0.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..735d2b8 --- /dev/null +++ b/README.md @@ -0,0 +1,243 @@ +# TZshot + +[中文](#中文) | [English](#english) + +--- + +## 中文 + +### 开源许可 +本项目采用 **GNU General Public License v3.0(GPL-3.0-only)** 开源。 +详见仓库根目录 [LICENSE](./LICENSE)。 + +### 项目简介 +`TZshot` 是一个基于 **Qt 6 + QML + C++** 的截图与贴图工具,支持多屏截图、标注、贴图编辑、OCR、GIF 录制、全局快捷键、系统托盘和 AI 图像编辑。 + +### 主要功能 +- 多屏截图(虚拟桌面坐标、跨屏场景) +- 标注工具: + - 矩形、圆形、箭头、画笔 + - 文字标注(文本内容、背景开关) + - 序号标注(支持自动递增) + - 高亮笔 + - 马赛克(可调强度) + - 撤销 +- 截图结果: + - 复制到剪贴板 + - 保存到文件 + - 贴图到桌面 +- 贴图窗口: + - 拖拽、透明度调整 + - 缩放、旋转、镜像、1:1 恢复 + - 标注、OCR、右键菜单 + - 完成后复制、另存为/覆盖闭环 +- GIF 录制(可调录制区域) +- OCR 识别结果浮窗 +- 全局快捷键(可配置并持久化) +- 系统托盘菜单 +- 中英文切换(切换后自动重启应用) +- AI 图像编辑(可配置模型与 API Key) + +### 技术栈 +- C++17 +- Qt 6(Core / Gui / Widgets / Quick / Qml / Network / QuickDialogs2 / LinguistTools) +- CMake +- 全局快捷键: + - Windows: `RegisterHotKey` + - Linux(X11): `xcb_grab_key` + +### 目录结构 +```text +TZshot/ +├─ src/ +│ ├─ main.cpp +│ ├─ model/ +│ ├─ viewmodel/ +│ ├─ paint_board/ +│ ├─ shortcut_key/ +│ ├─ ocr/ +│ └─ ai_call/ +├─ qml/ +├─ i18n/ +├─ resource/ +├─ thirdpart/ +└─ CMakeLists.txt +``` + +### 环境要求 +- CMake >= 3.16 +- Qt 6.x +- C++17 编译器 + +Windows: +- MSVC 2019/2022(推荐使用 Qt Creator 对应 Kit) + +Linux: +- X11/xcb 相关开发库 + +### 构建与运行 +#### 方式一:Qt Creator(推荐) +1. 打开项目根目录 `CMakeLists.txt` +2. 选择 Qt 6 Kit +3. Configure -> Build -> Run + +#### 方式二:命令行 +```bash +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --config Release +``` + +运行(Windows): +```bash +./build/Release/TZshot.exe +``` + +运行(Linux,路径以实际生成为准): +```bash +./build/TZshot +``` + +### 默认快捷键 +- `Alt + A`: 截图 +- `Alt + S`: 截图并保存 +- `Alt + P`: 贴图到桌面 +- `Alt + Q`: 显示/隐藏窗口 + +### 配置项(QSettings) +- AI + - `AI/apiKey` + - `AI/selectedModel` +- 快捷键 + - `Shortcuts/screenshot` + - `Shortcuts/screenshotSave` + - `Shortcuts/sticky` + - `Shortcuts/toggle` +- 通用 + - `App/language`(`zh_CN` / `en`) + - `ImageSaver/savePath` + +### 已知说明 +- Linux 全局快捷键目前基于 X11(暂不覆盖 Wayland 原生实现) +- GIF 与部分平台能力仍可继续做跨平台增强 + +### 贡献与安全提示 +- 请勿提交任何真实的 `API Key`、令牌或个人隐私数据。 +- 提交前建议自查编码与换行:`UTF-8(无 BOM)` + `LF`。 +- 若修改第三方依赖,请同步补充其许可证说明。 + +--- + +## English + +### License +This project is licensed under **GNU General Public License v3.0 (GPL-3.0-only)**. +See [LICENSE](./LICENSE) in the repository root. + +### Overview +`TZshot` is a screenshot and pin-image utility built with **Qt 6 + QML + C++**. +It supports multi-screen capture, rich annotations, sticky image editing, OCR, GIF recording, global shortcuts, tray integration, and AI image editing. + +### Key Features +- Multi-screen capture (virtual desktop coordinates) +- Annotation tools: + - Rectangle, circle, arrow, freehand pen + - Text annotation (text content + background toggle) + - Number annotation (auto-increment supported) + - Highlighter + - Mosaic (adjustable intensity) + - Undo +- Output actions: + - Copy to clipboard + - Save to file + - Pin to desktop (sticky window) +- Sticky window: + - Drag, opacity control + - Zoom, rotate, mirror, reset-to-1:1 + - Annotate, OCR, context menu actions + - Apply-and-copy / save-as / overwrite workflow +- GIF recording with adjustable capture area +- OCR result window +- Configurable global shortcuts (persisted) +- System tray menu +- Chinese/English UI switching (app restarts after switching) +- AI image editing with configurable model/API key + +### Tech Stack +- C++17 +- Qt 6 (Core / Gui / Widgets / Quick / Qml / Network / QuickDialogs2 / LinguistTools) +- CMake +- Global shortcuts: + - Windows: `RegisterHotKey` + - Linux(X11): `xcb_grab_key` + +### Project Structure +```text +TZshot/ +├─ src/ +├─ qml/ +├─ i18n/ +├─ resource/ +├─ thirdpart/ +└─ CMakeLists.txt +``` + +### Requirements +- CMake >= 3.16 +- Qt 6.x +- C++17 compiler + +Windows: +- MSVC 2019/2022 (Qt Creator kit recommended) + +Linux: +- X11/xcb development libraries + +### Build & Run +#### Option 1: Qt Creator (Recommended) +1. Open `CMakeLists.txt` in project root +2. Select a Qt 6 kit +3. Configure -> Build -> Run + +#### Option 2: Command Line +```bash +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --config Release +``` + +Run on Windows: +```bash +./build/Release/TZshot.exe +``` + +Run on Linux (path may vary by generator): +```bash +./build/TZshot +``` + +### Default Shortcuts +- `Alt + A`: Screenshot +- `Alt + S`: Screenshot and Save +- `Alt + P`: Pin to Desktop +- `Alt + Q`: Show/Hide Window + +### Configuration (QSettings) +- AI + - `AI/apiKey` + - `AI/selectedModel` +- Shortcuts + - `Shortcuts/screenshot` + - `Shortcuts/screenshotSave` + - `Shortcuts/sticky` + - `Shortcuts/toggle` +- General + - `App/language` (`zh_CN` / `en`) + - `ImageSaver/savePath` + +### Notes +- Linux global shortcuts currently rely on X11 (no native Wayland implementation yet) +- GIF and some platform-dependent capabilities can be further improved cross-platform + +### Contributing & Security +- Do not commit real API keys, tokens, or private data. +- Keep source files in `UTF-8 (no BOM)` with `LF` line endings. +- When adding/updating third-party dependencies, include their license notices. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..996cd20 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,18 @@ +# Third-Party Notices + +This project includes or depends on third-party components. +Please ensure license compatibility before distribution. + +## Included in source tree +- `src/viewmodel/stb_image_write.h` + License: Public domain / MIT (as stated in upstream file header) +- `src/gif_encoder/gif.h` + License: See upstream file header and original project license + +## Runtime / system dependencies +- Qt 6 +- Tesseract OCR +- Leptonica +- X11 / xcb (Linux) + +Please refer to each dependency's official repository for full license text. diff --git a/clearO.sh b/clearO.sh new file mode 100644 index 0000000..dba4ba7 --- /dev/null +++ b/clearO.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +# 功能:递归查找并删除指定目录下所有的 .o 文件 +# 作者:编程助手 +# 版本:1.0 + +# ======================== 配置区 ======================== +# 设置要扫描的根目录(默认为当前目录,你可以修改为绝对路径) +TARGET_DIR="." +# 是否开启交互模式(true=删除前确认,false=直接删除) +INTERACTIVE=true +# ======================================================= + +# 检查目标目录是否存在 +if [ ! -d "$TARGET_DIR" ]; then + echo "错误:目标目录 $TARGET_DIR 不存在!" + exit 1 +fi + +# 切换到目标目录 +cd "$TARGET_DIR" || exit 1 + +# 查找所有 .o 文件 +echo "正在查找 $TARGET_DIR 目录下的所有 .o 文件..." +O_FILES=$(find . -type f -name "*.o" -print) + +# 如果没有找到 .o 文件 +if [ -z "$O_FILES" ]; then + echo "未找到任何 .o 文件,无需删除。" + exit 0 +fi + +# 显示找到的 .o 文件列表 +echo -e "\n找到以下 .o 文件:" +echo "$O_FILES" + +# 交互模式确认 +if [ "$INTERACTIVE" = true ]; then + echo -e "\n是否确定删除以上所有 .o 文件?[y/N]" + read -r CONFIRM + if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then + echo "用户取消删除操作。" + exit 0 + fi +fi + +# 执行删除操作 +echo -e "\n开始删除 .o 文件..." +find . -type f -name "*.o" -delete + +# 验证删除结果 +REMAINING_FILES=$(find . -type f -name "*.o" -print) +if [ -z "$REMAINING_FILES" ]; then + echo "✅ 所有 .o 文件已成功删除!" +else + echo "⚠️ 部分 .o 文件删除失败,剩余文件:" + echo "$REMAINING_FILES" + exit 1 +fi + +exit 0 diff --git a/i18n/app_en.ts b/i18n/app_en.ts new file mode 100644 index 0000000..868a7f7 --- /dev/null +++ b/i18n/app_en.ts @@ -0,0 +1,795 @@ + + + + GifRecordViewModel + + + 录制区域无效 + + + + + 未采集到任何帧 + + + + + GIF 编码失败,请检查保存路径或磁盘空间 + + + + + QObject + + 开始截图 + Take Screenshot + + + 显示主窗口 + Show Main Window + + + + 截图 + Screenshot + + + + 设置 + Settings + + + + 关于 + About + + + + 退出 + Quit + + + 序号 + Number + + + + S: + + + + Don't translate the + + + + + TZAboutWin + + + 关于 TZshot + About TZshot + + + + 版本 v1.0.0 + Version v1.0.0 + + + + 简洁高效的截图贴图工具,支持标注、AI 编辑、马赛克等功能。 + A lightweight screenshot and annotation tool with AI editing, mosaic, and more. + + + + © 2025 TZshot. All rights reserved. + © 2025 TZshot. All rights reserved. + + + + 关闭 + Close + + + + TZChat + + + AI 图像编辑 + AI Image Edit + + + + 当前贴图 + Current Image + + + + 未关联贴图 + No Image Linked + + + + 发送后将对此图执行 AI 编辑 + AI edit will be applied to this image after sending + + + + 描述您想要的修改... + Describe the changes you want... + + + + 取消 + Cancel + + + + 发送 + Send + + + + TZGeneralSettings + + + 大模型设置 + AI Model Settings + + + + 大模型选择 + Select Model + + + + 阿里云百炼 + Alibaba Cloud Bailian + + + + 火山引擎 + Volcano Engine + + + + 请输入 API Key + Enter API Key + + + + 保存 + + + + + 语言设置 + Language + + + + 界面语言 + UI Language + + + + 截图保存路径 + Screenshot Save Path + + + + 保存路径 + Save Path + + + + 浏览 + Browse + + + + TZGifRecordBar + + + 正在生成 GIF... + + + + + TZHotkeySettings + + + 编辑快捷键 · %1 + Edit Shortcut · %1 + + + + 请按下新的快捷键组合... + Press the new shortcut key combination... + + + + 取消 + Cancel + + + + 确认 + Confirm + + + + + 截图 + Screenshot + + + + + 截图并保存 + Screenshot & Save + + + + + 贴图到桌面 + Pin to Desktop + + + + + 显示/隐藏窗口 + Show/Hide Window + + + + 全局快捷键 + Global Shortcuts + + + + 编辑 + Edit + + + + 说明 + Notes + + + + 快捷键在全局范围内生效。点击「编辑」后直接按下新的组合键,再点「确认」即可保存。 +若与系统或其他应用快捷键冲突,请修改为其他组合。 + Shortcuts work globally. Click "Edit", press the new key combination, then click "Confirm" to save. +If it conflicts with system or other app shortcuts, change to a different combination. + + + + TZMarkToolInfo + + + 文本 + + + + + 细 + Thin + + + + 中 + Medium + + + + 粗 + Thick + + + + 特粗 + Extra Thick + + + + 输入文字 + + + + + 背景 + + + + + 模糊 + + + + + 自动递增 + + + + + 数字 + + + + + TZOcrResult + + + OCR 识别结果 + OCR Results + + + + 正在识别中… + Recognizing… + + + + 识别结果将显示在这里… + Recognition results will appear here… + + + + 复制全部 + Copy All + + + + 关闭 + Close + + + + 已复制! + Copied! + + + + TZRightPanel + + + 通用设置 + General + + + + 快捷键设置 + Shortcuts + + + + TZSettingWin + + + 设置 + Settings + + + + 选择保存位置 + Choose Save Location + + + + TZStickyWindow + + + 当前透明度 %1% + Opacity: %1% + + + + + 覆盖贴图(合并标注) + Overwrite Image (Merge Annotations) + + + + + 显示工具栏 + Show Toolbar + + + + 透明度 +5% + Opacity +5% + + + + 透明度 -5% + Opacity -5% + + + + 旋转 90° + Rotate 90° + + + + 重置位置 + Reset Position + + + + + + AI 编辑 + AI Edit + + + 保存图片 + Save Image + + + + + + 复制贴图 + Copy Image + + + + + + 关闭窗口 + Close Window + + + + 保存原图 + Save Original Image + + + + + 另存为(合并标注) + Save As (Merge Annotations) + + + + 常用项置顶 + Pin Common Items + + + + 覆盖贴图 + Overwrite Image + + + + 显示/隐藏工具栏 + Show/Hide Toolbar + + + + 选择保存位置 + Choose Save Location + + + + Pencil + Pencil + + + + Rectangle + Rectangle + + + + Circle + Circle + + + + Arrow + Arrow + + + + + Mosaic + Mosaic + + + + + 缩小 + Zoom Out + + + + + 放大 + Zoom In + + + + 旋转90° + Rotate 90° + + + + + 镜像 + Mirror + + + + + 恢复原始大小 + Reset Size + + + + 完成并复制 + Apply and Copy + + + + 撤销 + Undo + + + + + + + 隐藏工具栏 + Hide Toolbar + + + 序号 + Number + + + 旋转90° + Rotate 90° + + + 恢复原始大小 + Reset Size + + + 隐藏工具栏 + Hide Toolbar + + + + + OCR + OCR + + + + TZTool + + 确认截图 + Confirm + + + + 取消 + Cancel + + + 保存到文件 + Save to File + + + 贴图到桌面 + Pin to Desktop + + + 矩形标注 + Rectangle + + + 椭圆标注 + Ellipse + + + 箭头标注 + Arrow + + + + 完成 + Done + + + + 录制GIF + Record GIF + + + + 保存 + Save + + + + 贴图 + Pin + + + + 矩形 + Rectangle + + + + 圆形 + Circle + + + + 箭头 + Arrow + + + + 画笔 + Pen + + + + 高亮 + Highlight + + + + 马赛克 + Mosaic + + + + 文字 + Text + + + + 序号 + Number + + + + 撤销 + Undo + + + 序号 + Number + + + + OCR + OCR + + + 录制GIF + Record GIF + + + 马赛克 + Mosaic + + + + TZWindow + + 选择保存位置 + Choose Save Location + + + 保存成功 + Saved + + + 截图已保存到文件 + Screenshot saved to file + + + 保存失败 + Save Failed + + + 截图文件保存失败 + Failed to save screenshot file + + + + %1 * %2 + %1 * %2 + + + 截图完成 + Done + + + 已复制到剪贴板 + Copied to clipboard + + + 截图失败 + Screenshot Failed + + + 截图复制到剪贴板失败 + Failed to copy screenshot to clipboard + + + + Capture done + Capture done + + + + Copied to clipboard + Copied to clipboard + + + + Capture failed + Capture failed + + + + Failed to copy image + Failed to copy image + + + + Select save location + Select save location + + + + Save success + Save success + + + + Screenshot saved to file + Screenshot saved to file + + + + Save failed + Save failed + + + + Failed to save screenshot file + Failed to save screenshot file + + + + Cancel + Cancel + + + + OK + OK + + + + main + + + TZshot + TZshot + + + + OCR 识别失败 + OCR Recognition Failed + + + diff --git a/i18n/app_zh_CN.ts b/i18n/app_zh_CN.ts new file mode 100644 index 0000000..9f7ee39 --- /dev/null +++ b/i18n/app_zh_CN.ts @@ -0,0 +1,691 @@ + + + + + GifRecordViewModel + + + 录制区域无效 + + + + + 未采集到任何帧 + + + + + GIF 编码失败,请检查保存路径或磁盘空间 + + + + + QObject + + + 截图 + + + + + 设置 + + + + + 关于 + + + + + 退出 + + + + + S: + + + + Don't translate the + + + + + TZAboutWin + + + 关于 TZshot + + + + + 版本 v1.0.0 + + + + + 简洁高效的截图贴图工具,支持标注、AI 编辑、马赛克等功能。 + + + + + © 2025 TZshot. All rights reserved. + + + + + 关闭 + + + + + TZChat + + + AI 图像编辑 + + + + + 当前贴图 + + + + + 未关联贴图 + + + + + 发送后将对此图执行 AI 编辑 + + + + + 描述您想要的修改... + + + + + 取消 + + + + + 发送 + + + + + TZGeneralSettings + + + 大模型设置 + + + + + 大模型选择 + + + + + 阿里云百炼 + + + + + 火山引擎 + + + + + 请输入 API Key + + + + + 保存 + + + + + 语言设置 + + + + + 界面语言 + + + + + 截图保存路径 + + + + + 保存路径 + + + + + 浏览 + + + + + TZGifRecordBar + + + 正在生成 GIF... + + + + + TZHotkeySettings + + + 编辑快捷键 · %1 + + + + + 请按下新的快捷键组合... + + + + + 取消 + + + + + 确认 + + + + + + 截图 + + + + + + 截图并保存 + + + + + + 贴图到桌面 + + + + + + 显示/隐藏窗口 + + + + + 全局快捷键 + + + + + 编辑 + + + + + 说明 + + + + + 快捷键在全局范围内生效。点击「编辑」后直接按下新的组合键,再点「确认」即可保存。 +若与系统或其他应用快捷键冲突,请修改为其他组合。 + + + + + TZMarkToolInfo + + + 文本 + + + + + 细 + + + + + 中 + + + + + 粗 + + + + + 特粗 + + + + + 输入文字 + + + + + 背景 + + + + + 模糊 + + + + + 自动递增 + + + + + 数字 + + + + + TZOcrResult + + + OCR 识别结果 + + + + + 正在识别中… + + + + + 识别结果将显示在这里… + + + + + 复制全部 + + + + + 关闭 + + + + + 已复制! + + + + + TZRightPanel + + + 通用设置 + + + + + 快捷键设置 + + + + + TZSettingWin + + + 设置 + + + + + 选择保存位置 + + + + + TZStickyWindow + + + + OCR + + + + + Pencil + + + + + Rectangle + + + + + Circle + + + + + Arrow + + + + + + Mosaic + + + + + + 缩小 + + + + + + 放大 + + + + + 旋转90° + + + + + + 镜像 + + + + + + 恢复原始大小 + + + + + 完成并复制 + + + + + 撤销 + + + + + + + + 隐藏工具栏 + + + + + 当前透明度 %1% + + + + + + 覆盖贴图(合并标注) + + + + + + + 复制贴图 + + + + + + + AI 编辑 + + + + + + 显示工具栏 + + + + + + + 关闭窗口 + + + + + 透明度 +5% + + + + + 透明度 -5% + + + + + 旋转 90° + + + + + 重置位置 + + + + + 保存原图 + + + + + + 另存为(合并标注) + + + + + 常用项置顶 + + + + + 覆盖贴图 + + + + + 显示/隐藏工具栏 + + + + + 选择保存位置 + + + + + TZTool + + + 取消 + + + + + 完成 + + + + + OCR + + + + + 录制GIF + + + + + 保存 + + + + + 贴图 + + + + + 矩形 + + + + + 圆形 + + + + + 箭头 + + + + + 画笔 + + + + + 高亮 + + + + + 马赛克 + + + + + 文字 + + + + + 序号 + + + + + 撤销 + + + + + TZWindow + + + Capture done + + + + + Copied to clipboard + + + + + Capture failed + + + + + Failed to copy image + + + + + Select save location + + + + + Save success + + + + + Screenshot saved to file + + + + + Save failed + + + + + Failed to save screenshot file + + + + + %1 * %2 + + + + + Cancel + + + + + OK + + + + + main + + + TZshot + + + + + OCR 识别失败 + + + + diff --git a/i18n/i18n.qrc b/i18n/i18n.qrc new file mode 100644 index 0000000..efe8d67 --- /dev/null +++ b/i18n/i18n.qrc @@ -0,0 +1,6 @@ + + + app_zh_CN.qm + app_en.qm + + diff --git a/qml.qrc b/qml.qrc new file mode 100644 index 0000000..ebf2bed --- /dev/null +++ b/qml.qrc @@ -0,0 +1,47 @@ + + + qml/main.qml + qml/TZStickyWindow.qml + qml/TZWindow.qml + qml/TZScreenOverlay.qml + qml/TZLable.qml + qml/TZTool.qml + qml/TZResizeHandle.qml + qml/TZResizeHandleGroup.qml + qml/TZChat.qml + qml/Setting/TZSettingWin.qml + qml/Setting/TZGeneralSettings.qml + qml/Setting/TZRightPanel.qml + qml/TZControls/qmldir + qml/TZControls/TZComboBox.qml + qml/Setting/TZHotkeySettings.qml + qml/Setting/TZAboutWin.qml + resource/img/lc_check.svg + resource/img/lc_x.svg + resource/img/lc_save.svg + resource/img/lc_pin.svg + resource/img/lc_square.svg + resource/img/lc_pencil.svg + resource/img/lc_circle.svg + resource/img/lc_arrow.svg + resource/img/lc_mosaic.svg + resource/img/lc_text.svg + resource/img/lc_list_ordered.svg + resource/img/lc_highlighter.svg + resource/img/lc_undo.svg + resource/img/tray_icon.svg + qml/TZMarkToolInfo.qml + qml/TZMagnifyingGlass.qml + resource/img/lc_close.svg + resource/img/lc_chat.svg + resource/img/lc_emoji.svg + resource/img/lc_file.svg + resource/img/lc_send.svg + qml/TZLoadingSpinner.qml + qml/TZGifRecordBar.qml + qml/TZOcrResult.qml + resource/img/lc_gif_record.svg + resource/img/lc_gif_stop.svg + resource/img/lc_ocr.svg + + diff --git a/qml/Setting/TZAboutWin.qml b/qml/Setting/TZAboutWin.qml new file mode 100644 index 0000000..0e9e0e9 --- /dev/null +++ b/qml/Setting/TZAboutWin.qml @@ -0,0 +1,128 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Layouts + +Window { + id: aboutWin + width: 400 + height: 350 + title: qsTr("关于 TZshot") + color: "#F8FAFC" + flags: Qt.Dialog | Qt.WindowCloseButtonHint + minimumWidth: width + maximumWidth: width + minimumHeight: height + maximumHeight: height + + Component.onCompleted: { + x = (Screen.width - width) / 2 + y = (Screen.height - height) / 2 + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 32 + spacing: 0 + + // ── Logo 区 ────────────────────────────────── + Item { + Layout.alignment: Qt.AlignHCenter + width: 72; height: 72 + + Rectangle { + anchors.fill: parent + radius: 18 + color: "#3B82F6" + + Text { + anchors.centerIn: parent + text: "T" + font.pixelSize: 40 + font.bold: true + color: "#FFFFFF" + } + } + } + + Item { Layout.preferredHeight: 16 } + + // ── 应用名称 ────────────────────────────────── + Text { + Layout.alignment: Qt.AlignHCenter + text: "TZshot" + font.pixelSize: 22 + font.bold: true + color: "#1E293B" + } + + Item { Layout.preferredHeight: 6 } + + // ── 版本号 ──────────────────────────────────── + Text { + Layout.alignment: Qt.AlignHCenter + text: qsTr("版本 v1.0.0") + font.pixelSize: 13 + color: "#64748B" + } + + Item { Layout.preferredHeight: 16 } + + // ── 分割线 ──────────────────────────────────── + Rectangle { + Layout.fillWidth: true + height: 1 + color: "#E2E8F0" + } + + Item { Layout.preferredHeight: 16 } + + // ── 描述 ────────────────────────────────────── + Text { + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + text: qsTr("简洁高效的截图贴图工具,支持标注、AI 编辑、马赛克等功能。") + font.pixelSize: 13 + color: "#475569" + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + + Item { Layout.fillHeight: true } + + // ── 版权 ────────────────────────────────────── + Text { + Layout.alignment: Qt.AlignHCenter + Layout.bottomMargin: 4 + text: qsTr("© 2025 TZshot. All rights reserved.") + font.pixelSize: 11 + color: "#94A3B8" + } + + // ── 关闭按钮 ────────────────────────────────── + Rectangle { + Layout.alignment: Qt.AlignHCenter + width: 88; height: 34 + radius: 8 + color: closeMouse.containsMouse ? "#2563EB" : "#3B82F6" + + Behavior on color { ColorAnimation { duration: 100 } } + + Text { + anchors.centerIn: parent + text: qsTr("关闭") + font.pixelSize: 13 + color: "#FFFFFF" + } + + MouseArea { + id: closeMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: aboutWin.close() + } + } + + Item { Layout.preferredHeight: 4 } + } +} diff --git a/qml/Setting/TZGeneralSettings.qml b/qml/Setting/TZGeneralSettings.qml new file mode 100644 index 0000000..35c81d6 --- /dev/null +++ b/qml/Setting/TZGeneralSettings.qml @@ -0,0 +1,336 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls.Basic +import TZControls 1.0 + +Rectangle { + color: "#F8FAFC" + + ScrollView { + id: scrollView + anchors.fill: parent + contentWidth: availableWidth + clip: true + + ColumnLayout { + // 必须绑定到 ScrollView.availableWidth,不能用 parent.width + // (ScrollView 内部 contentItem 宽度不受限) + x: 24 + width: scrollView.availableWidth - 48 + spacing: 8 + + Item { Layout.preferredHeight: 4 } + + // ── 大模型设置 标题 ────────────────────────── + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 8 + Rectangle { + width: 3; height: 15; radius: 2 + color: "#3B82F6" + Layout.alignment: Qt.AlignVCenter + } + Text { + text: qsTr("大模型设置") + font.pixelSize: 13 + font.bold: true + color: "#1E293B" + } + } + + // ── 大模型设置 卡片 ────────────────────────── + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 120 + color: "#FFFFFF" + radius: 10 + border.color: "#E2E8F0" + border.width: 1 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + // 大模型选择 + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Text { + Layout.preferredWidth: 90 + text: qsTr("大模型选择") + font.pixelSize: 13 + color: "#374151" + Layout.alignment: Qt.AlignVCenter + } + + TZComboBox { + id: combobox + model: [qsTr("阿里云百炼"), qsTr("火山引擎")] + currentIndex: O_AIViewModel.selectedModel + Layout.preferredWidth: 220 + Layout.preferredHeight: 36 + + onCurrentIndexChanged: { + O_AIViewModel.setSelectedModel(currentIndex) + } + } + } + + // API Key + RowLayout { + Layout.fillWidth: true + spacing: 12 + + Text { + Layout.preferredWidth: 90 + text: "API Key" + font.pixelSize: 13 + color: "#374151" + Layout.alignment: Qt.AlignVCenter + } + + Rectangle { + Layout.preferredWidth: 320 + Layout.preferredHeight: 36 + radius: 6 + color: apiKeyInput.activeFocus ? "#EFF6FF" : "#F8FAFC" + border.color: apiKeyInput.activeFocus ? "#3B82F6" : "#E2E8F0" + border.width: apiKeyInput.activeFocus ? 2 : 1 + + Behavior on border.color { ColorAnimation { duration: 150 } } + Behavior on color { ColorAnimation { duration: 150 } } + + TextInput { + id: apiKeyInput + anchors.fill: parent + anchors.margins: 10 + font.pixelSize: 13 + color: "#1E293B" + selectByMouse: true + verticalAlignment: Text.AlignVCenter + clip: true + + // 初始化时从后端读取已持久化的 apiKey + Component.onCompleted: { + text = O_AIViewModel.apiKey + } + + Text { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + text: qsTr("请输入 API Key") + font.pixelSize: 13 + color: "#CBD5E1" + visible: !apiKeyInput.text && !apiKeyInput.activeFocus + } + } + } + + // 保存按钮 + Rectangle { + id: saveApiKeyBtn + Layout.preferredWidth: 60 + Layout.preferredHeight: 36 + radius: 6 + property bool modified: apiKeyInput.text !== O_AIViewModel.apiKey + color: { + if (!modified) return "#E2E8F0" + if (saveArea.containsMouse) return "#2563EB" + return "#3B82F6" + } + Behavior on color { ColorAnimation { duration: 120 } } + + Text { + anchors.centerIn: parent + text: qsTr("保存") + font.pixelSize: 13 + color: saveApiKeyBtn.modified ? "#FFFFFF" : "#94A3B8" + } + + MouseArea { + id: saveArea + anchors.fill: parent + hoverEnabled: true + cursorShape: saveApiKeyBtn.modified ? Qt.PointingHandCursor : Qt.ArrowCursor + enabled: saveApiKeyBtn.modified + onClicked: { + O_AIViewModel.setApiKey(apiKeyInput.text) + } + } + } + } + + Item { Layout.fillHeight: true } + } + } + + Item { Layout.preferredHeight: 8 } + + // ── 语言设置 标题 ──────────────────────────── + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 8 + Rectangle { + width: 3; height: 15; radius: 2 + color: "#3B82F6" + Layout.alignment: Qt.AlignVCenter + } + Text { + text: qsTr("语言设置") + font.pixelSize: 13 + font.bold: true + color: "#1E293B" + } + } + + // ── 语言设置 卡片 ──────────────────────────── + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 72 + color: "#FFFFFF" + radius: 10 + border.color: "#E2E8F0" + border.width: 1 + + RowLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Text { + Layout.preferredWidth: 90 + text: qsTr("界面语言") + font.pixelSize: 13 + color: "#374151" + Layout.alignment: Qt.AlignVCenter + } + + TZComboBox { + id: langComboBox + // Index 0 = zh_CN, Index 1 = en + model: ["中文", "English"] + currentIndex: O_LanguageManager.language === "en" ? 1 : 0 + Layout.preferredWidth: 160 + Layout.preferredHeight: 36 + + onCurrentIndexChanged: { + var lang = currentIndex === 1 ? "en" : "zh_CN" + O_LanguageManager.setLanguage(lang) + } + } + } + } + + Item { Layout.preferredHeight: 8 } + + // ── 截图保存路径 标题 ──────────────────────── + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 8 + Rectangle { + width: 3; height: 15; radius: 2 + color: "#3B82F6" + Layout.alignment: Qt.AlignVCenter + } + Text { + text: qsTr("截图保存路径") + font.pixelSize: 13 + font.bold: true + color: "#1E293B" + } + } + + // ── 截图保存路径 卡片 ──────────────────────── + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 72 + color: "#FFFFFF" + radius: 10 + border.color: "#E2E8F0" + border.width: 1 + + RowLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Text { + Layout.preferredWidth: 90 + text: qsTr("保存路径") + font.pixelSize: 13 + color: "#374151" + Layout.alignment: Qt.AlignVCenter + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 36 + radius: 6 + color: "#F8FAFC" + border.color: "#E2E8F0" + border.width: 1 + + Text { + id: saveFolder + anchors.fill: parent + anchors.margins: 10 + font.pixelSize: 13 + color: "#374151" + text: O_ImageSaver.savePath + elide: Text.ElideMiddle + verticalAlignment: Text.AlignVCenter + clip: true + wrapMode: Text.NoWrap + + ToolTip { + id: folderTip + visible: false + text: saveFolder.text + delay: 500 + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: folderTip.visible = true + onExited: folderTip.visible = false + } + } + } + + // 浏览按钮 + Rectangle { + Layout.preferredWidth: 72 + Layout.preferredHeight: 36 + radius: 6 + color: browseArea.containsMouse ? "#2563EB" : "#3B82F6" + Behavior on color { ColorAnimation { duration: 120 } } + + Text { + anchors.centerIn: parent + text: qsTr("浏览") + font.pixelSize: 13 + color: "#FFFFFF" + } + + MouseArea { + id: browseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: saveDialog.open() + } + } + } + } + + Item { Layout.fillHeight: true } + } + } +} + diff --git a/qml/Setting/TZHotkeySettings.qml b/qml/Setting/TZHotkeySettings.qml new file mode 100644 index 0000000..3bfdcef --- /dev/null +++ b/qml/Setting/TZHotkeySettings.qml @@ -0,0 +1,373 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls.Basic + +Rectangle { + color: "#F8FAFC" + + // ── 按键录制弹窗 ────────────────────────────────────── + Rectangle { + id: recordOverlay + anchors.fill: parent + color: "#80000000" + visible: false + z: 100 + + property int recordingActionId: 0 + property string recordingLabel: "" + property string pendingKeySeq: "" + + // 捕获键盘焦点以接收按键事件 + focus: visible + + Keys.onPressed: function(event) { + event.accepted = true + var seq = O_GlobalShortcut.buildKeySequence(event.key, event.modifiers) + if (seq !== "") { + recordOverlay.pendingKeySeq = seq + } + } + + // 弹窗卡片 + Rectangle { + anchors.centerIn: parent + width: 360 + height: 220 + radius: 12 + color: "#FFFFFF" + border.color: "#E2E8F0" + border.width: 1 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 24 + spacing: 16 + + // 标题 + Text { + Layout.fillWidth: true + text: qsTr("编辑快捷键 · %1").arg(recordOverlay.recordingLabel) + font.pixelSize: 14 + font.bold: true + color: "#1E293B" + } + + // 录制区域 + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 56 + radius: 8 + color: "#F1F5F9" + border.color: recordOverlay.pendingKeySeq !== "" ? "#3B82F6" : "#CBD5E1" + border.width: recordOverlay.pendingKeySeq !== "" ? 2 : 1 + + Behavior on border.color { ColorAnimation { duration: 120 } } + + Text { + anchors.centerIn: parent + text: recordOverlay.pendingKeySeq !== "" + ? recordOverlay.pendingKeySeq + : qsTr("请按下新的快捷键组合...") + font.pixelSize: 15 + font.family: "Monospace" + color: recordOverlay.pendingKeySeq !== "" ? "#1E293B" : "#94A3B8" + } + } + + // 冲突提示 + Text { + id: conflictHint + Layout.fillWidth: true + font.pixelSize: 12 + color: "#EF4444" + visible: false + wrapMode: Text.WordWrap + } + + // 按钮行 + RowLayout { + Layout.fillWidth: true + spacing: 8 + + // 取消 + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 36 + radius: 6 + color: cancelArea.containsMouse ? "#F1F5F9" : "#F8FAFC" + border.color: "#E2E8F0" + Behavior on color { ColorAnimation { duration: 100 } } + + Text { + anchors.centerIn: parent + text: qsTr("取消") + font.pixelSize: 13 + color: "#64748B" + } + MouseArea { + id: cancelArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + recordOverlay.visible = false + recordOverlay.pendingKeySeq = "" + conflictHint.visible = false + } + } + } + + // 确认 + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 36 + radius: 6 + color: { + if (recordOverlay.pendingKeySeq === "") return "#93C5FD" + return confirmArea.containsMouse ? "#2563EB" : "#3B82F6" + } + Behavior on color { ColorAnimation { duration: 100 } } + + Text { + anchors.centerIn: parent + text: qsTr("确认") + font.pixelSize: 13 + color: "#FFFFFF" + } + MouseArea { + id: confirmArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: recordOverlay.pendingKeySeq !== "" + onClicked: { + var seq = recordOverlay.pendingKeySeq + var aid = recordOverlay.recordingActionId + + // 检查冲突 + var conflictId = O_GlobalShortcut.checkConflict(seq) + if (conflictId !== 0 && conflictId !== aid) { + var labels = ["", qsTr("截图"), qsTr("截图并保存"), qsTr("贴图到桌面"), qsTr("显示/隐藏窗口")] + conflictHint.text = "「" + seq + "」已被「" + labels[conflictId] + "」使用,请换一个" + conflictHint.visible = true + return + } + + // 应用 + if (O_GlobalShortcut.updateShortcut(aid, seq)) { + recordOverlay.visible = false + recordOverlay.pendingKeySeq = "" + conflictHint.visible = false + } else { + conflictHint.text = "快捷键「" + seq + "」注册失败,请换一个组合" + conflictHint.visible = true + } + } + } + } + } + } + } + } + + // ── 主内容区 ────────────────────────────────────────── + ScrollView { + anchors.fill: parent + contentWidth: availableWidth + clip: true + + ColumnLayout { + width: parent.width + spacing: 8 + + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 24 + anchors.topMargin: 24 + + Item { Layout.preferredHeight: 4 } + + // ── 标题 ──────────────────────────────────── + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 8 + Rectangle { + width: 3; height: 15; radius: 2 + color: "#3B82F6" + Layout.alignment: Qt.AlignVCenter + } + Text { + text: qsTr("全局快捷键") + font.pixelSize: 13 + font.bold: true + color: "#1E293B" + } + } + + // ── 快捷键列表卡片 ─────────────────────────── + Rectangle { + Layout.fillWidth: true + implicitHeight: hotkeyCol.implicitHeight + 32 + color: "#FFFFFF" + radius: 10 + border.color: "#E2E8F0" + border.width: 1 + + ColumnLayout { + id: hotkeyCol + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 16 + spacing: 0 + + Repeater { + // 动态从后端读取当前快捷键 + model: [ + { label: qsTr("截图"), actionId: 1, keySeq: O_GlobalShortcut.shortcutScreenshot }, + { label: qsTr("截图并保存"), actionId: 2, keySeq: O_GlobalShortcut.shortcutScreenshotSave }, + { label: qsTr("贴图到桌面"), actionId: 3, keySeq: O_GlobalShortcut.shortcutSticky }, + { label: qsTr("显示/隐藏窗口"), actionId: 4, keySeq: O_GlobalShortcut.shortcutToggle } + ] + + delegate: ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 12 + Layout.bottomMargin: 12 + + // 功能名称 + Text { + Layout.preferredWidth: 120 + text: modelData.label + font.pixelSize: 13 + color: "#374151" + Layout.alignment: Qt.AlignVCenter + } + + // 快捷键徽章 + RowLayout { + spacing: 4 + Layout.alignment: Qt.AlignVCenter + + Repeater { + model: modelData.keySeq.split("+") + delegate: Rectangle { + implicitWidth: kbLabel.implicitWidth + 16 + implicitHeight: 28 + radius: 5 + color: "#F1F5F9" + border.color: "#CBD5E1" + border.width: 1 + Text { + id: kbLabel + anchors.centerIn: parent + text: modelData + font.pixelSize: 12 + font.family: "Monospace" + color: "#374151" + } + } + } + } + + Item { Layout.fillWidth: true } + + // 编辑按钮 + Rectangle { + implicitWidth: 60 + implicitHeight: 28 + radius: 5 + color: editArea.containsMouse ? "#EFF6FF" : "#F8FAFC" + border.color: editArea.containsMouse ? "#3B82F6" : "#E2E8F0" + border.width: 1 + Behavior on color { ColorAnimation { duration: 100 } } + Behavior on border.color{ ColorAnimation { duration: 100 } } + + Text { + anchors.centerIn: parent + text: qsTr("编辑") + font.pixelSize: 12 + color: editArea.containsMouse ? "#3B82F6" : "#64748B" + Behavior on color { ColorAnimation { duration: 100 } } + } + + MouseArea { + id: editArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + recordOverlay.recordingActionId = modelData.actionId + recordOverlay.recordingLabel = modelData.label + recordOverlay.pendingKeySeq = "" + conflictHint.visible = false + recordOverlay.visible = true + recordOverlay.forceActiveFocus() + } + } + } + } + + // 分割线(最后一行不显示) + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: "#F1F5F9" + visible: index < 3 + } + } + } + } + } + + Item { Layout.preferredHeight: 8 } + + // ── 说明 ──────────────────────────────────── + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 8 + Rectangle { + width: 3; height: 15; radius: 2 + color: "#3B82F6" + Layout.alignment: Qt.AlignVCenter + } + Text { + text: qsTr("说明") + font.pixelSize: 13 + font.bold: true + color: "#1E293B" + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: noteText.implicitHeight + 24 + color: "#FFFFFF" + radius: 10 + border.color: "#E2E8F0" + border.width: 1 + + Text { + id: noteText + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 16 + text: qsTr("快捷键在全局范围内生效。点击「编辑」后直接按下新的组合键,再点「确认」即可保存。\n若与系统或其他应用快捷键冲突,请修改为其他组合。") + font.pixelSize: 13 + color: "#64748B" + wrapMode: Text.WordWrap + lineHeight: 1.6 + } + } + + Item { Layout.fillHeight: true } + } + } +} diff --git a/qml/Setting/TZRightPanel.qml b/qml/Setting/TZRightPanel.qml new file mode 100644 index 0000000..ca8681e --- /dev/null +++ b/qml/Setting/TZRightPanel.qml @@ -0,0 +1,166 @@ +import QtQuick +import QtQuick.Layouts + +Rectangle { + signal signalSetting() + signal signalHotkeySettings() + + property string selectName: "setting" + color: "#F1F5F9" + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // 顶部 Logo / 标题区 + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 72 + color: "transparent" + + ColumnLayout { + anchors.centerIn: parent + spacing: 4 + + Rectangle { + width: 36 + height: 36 + radius: 10 + color: "#3B82F6" + Layout.alignment: Qt.AlignHCenter + + Text { + anchors.centerIn: parent + text: "T" + font.pixelSize: 20 + font.bold: true + color: "#FFFFFF" + } + } + + Text { + Layout.alignment: Qt.AlignHCenter + text: "TZshot" + font.pixelSize: 12 + color: "#64748B" + } + } + } + + // 分割线 + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + color: "#E2E8F0" + } + + Item { Layout.preferredHeight: 12 } + + // ── 通用设置 菜单项 ────────────────────── + Item { + Layout.fillWidth: true + Layout.preferredHeight: 44 + Layout.leftMargin: 10 + Layout.rightMargin: 10 + + Rectangle { + anchors.fill: parent + radius: 8 + color: selectName === "setting" ? "#FFFFFF" : "transparent" + border.color: selectName === "setting" ? "#E2E8F0" : "transparent" + border.width: 1 + } + + Rectangle { + width: 3 + height: 20 + radius: 2 + anchors.left: parent.left + anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + color: selectName === "setting" ? "#3B82F6" : "transparent" + } + + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 18 + text: qsTr("通用设置") + font.pixelSize: 13 + font.bold: selectName === "setting" + color: selectName === "setting" ? "#3B82F6" : "#64748B" + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + selectName = "setting" + signalSetting() + } + } + } + + Item { Layout.preferredHeight: 4 } + + // ── 快捷键设置 菜单项 ──────────────────── + Item { + Layout.fillWidth: true + Layout.preferredHeight: 44 + Layout.leftMargin: 10 + Layout.rightMargin: 10 + + Rectangle { + anchors.fill: parent + radius: 8 + color: selectName === "shortcutKey" ? "#FFFFFF" : "transparent" + border.color: selectName === "shortcutKey" ? "#E2E8F0" : "transparent" + border.width: 1 + } + + Rectangle { + width: 3 + height: 20 + radius: 2 + anchors.left: parent.left + anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + color: selectName === "shortcutKey" ? "#3B82F6" : "transparent" + } + + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 18 + text: qsTr("快捷键设置") + font.pixelSize: 13 + font.bold: selectName === "shortcutKey" + color: selectName === "shortcutKey" ? "#3B82F6" : "#64748B" + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + selectName = "shortcutKey" + signalHotkeySettings() + } + } + } + + // 底部弹性空间 + Item { Layout.fillHeight: true } + + // 版本号 + Text { + Layout.alignment: Qt.AlignHCenter + Layout.bottomMargin: 12 + text: "v1.0.0" + font.pixelSize: 11 + color: "#CBD5E1" + } + } +} + diff --git a/qml/Setting/TZSettingWin.qml b/qml/Setting/TZSettingWin.qml new file mode 100644 index 0000000..59686a3 --- /dev/null +++ b/qml/Setting/TZSettingWin.qml @@ -0,0 +1,77 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Layouts +import QtQuick.Controls.Basic +import QtQuick.Dialogs +import TZControls 1.0 + +Window { + width: 750 + height: 520 + title: qsTr("设置") + visible: true + color: "#F8FAFC" + property string currentPage: "general" + + Component.onCompleted: centerWindow() + onScreenChanged: centerWindow() + + function centerWindow() { + if (Screen) { + const screen = this.screen || Screen.primaryScreen + x = (screen.width - width) / 2 + y = (screen.height - height) / 2 + } + } + + FolderDialog { + id: saveDialog + title: qsTr("选择保存位置") + currentFolder: O_ImageSaver.savePath + onAccepted: { + O_ImageSaver.setSavePath(saveDialog.selectedFolder) + } + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + // 左侧导航栏 + TZRightPanel { + Layout.preferredWidth: 160 + Layout.fillHeight: true + onSignalSetting: { + currentPage = "general" + } + onSignalHotkeySettings: { + currentPage = "hotkey" + } + } + + // 分割线 + Rectangle { + Layout.preferredWidth: 1 + Layout.fillHeight: true + color: "#E2E8F0" + } + + // 右侧内容区 + StackLayout { + Layout.fillHeight: true + Layout.fillWidth: true + currentIndex: currentPage === "general" ? 0 : 1 + + TZGeneralSettings { + Layout.fillHeight: true + Layout.fillWidth: true + } + + TZHotkeySettings { + Layout.fillHeight: true + Layout.fillWidth: true + } + } + } +} + diff --git a/qml/TZChat.qml b/qml/TZChat.qml new file mode 100644 index 0000000..ac6aa6e --- /dev/null +++ b/qml/TZChat.qml @@ -0,0 +1,264 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Layouts +import QtQuick.Controls.Basic + +QtObject { + id: chatPanel + + // 对应贴图的图片 URL,由外部绑定 + property string imageUrl: "" + + signal signalSendPrompt(var text) + + // 公开方法 + function open(px, py) { + win.x = px + win.y = py + win.visible = true + win.raise() + win.requestActivate() + } + + function close() { + win.visible = false + } + + // ── 内部 Window ───────────────────────────────────── + property var win: Window { + id: win + flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool + color: "transparent" + width: 420 + height: 380 + visible: false + + Rectangle { + anchors.fill: parent + color: "#ffffff" + radius: 10 + border.color: "#E2E8F0" + border.width: 1 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + + // ── 顶部标题栏 ──────────────────────────────────── + Rectangle { + Layout.fillWidth: true + height: 36 + color: "transparent" + + // 拖动区域(z:-1 让关闭按钮的点击不被拦截) + MouseArea { + id: dragHandle + anchors.fill: parent + z: -1 + property point startPos + + onPressed: startPos = Qt.point(mouseX, mouseY) + onPositionChanged: { + if (pressed) { + win.x += mouseX - startPos.x + win.y += mouseY - startPos.y + } + } + cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor + } + + RowLayout { + anchors.fill: parent + spacing: 8 + + Image { + source: "qrc:/resource/img/lc_chat.svg" + width: 20 + height: 20 + fillMode: Image.PreserveAspectFit + } + + Text { + text: qsTr("AI 图像编辑") + font.pixelSize: 14 + font.weight: Font.Medium + color: "#1E293B" + } + + Item { Layout.fillWidth: true } + + // 关闭按钮 + Rectangle { + width: 28 + height: 28 + radius: 6 + color: clearMouse.containsMouse ? "#FEE2E2" : "transparent" + + Image { + anchors.centerIn: parent + source: "qrc:/resource/img/lc_close.svg" + width: 14 + height: 14 + fillMode: Image.PreserveAspectFit + } + + MouseArea { + id: clearMouse + anchors.fill: parent + hoverEnabled: true + onClicked: chatPanel.close() + } + } + } + + // 标题栏底部分割线 + Rectangle { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: "#E2E8F0" + } + } + + // ── 贴图缩略图预览 ──────────────────────────────── + Rectangle { + Layout.fillWidth: true + height: 64 + radius: 6 + color: "#F1F5F9" + border.color: "#E2E8F0" + border.width: 1 + clip: true + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 10 + + // 缩略图 + Rectangle { + width: 48 + height: 48 + radius: 4 + color: "#E2E8F0" + clip: true + + Image { + anchors.fill: parent + source: chatPanel.imageUrl + fillMode: Image.PreserveAspectCrop + cache: false + } + } + + // 文字说明 + Column { + Layout.fillWidth: true + spacing: 3 + + Text { + text: qsTr("当前贴图") + font.pixelSize: 11 + color: "#94A3B8" + } + + Text { + width: parent.width + text: chatPanel.imageUrl !== "" + ? chatPanel.imageUrl.replace(/^image:\/\/[^/]+\//, "ID: ") + : qsTr("未关联贴图") + font.pixelSize: 12 + color: "#475569" + elide: Text.ElideRight + } + + Text { + text: qsTr("发送后将对此图执行 AI 编辑") + font.pixelSize: 10 + color: "#94A3B8" + } + } + } + } + + // ── 输入区域 ────────────────────────────────────── + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: "#F8FAFC" + radius: 8 + border.color: "#E2E8F0" + border.width: 1 + + TextArea { + id: input + anchors.fill: parent + anchors.margins: 12 + font.family: "Segoe UI" + font.pixelSize: 13 + color: "#1E293B" + wrapMode: Text.Wrap + placeholderText: qsTr("描述您想要的修改...") + background: Rectangle { color: "transparent" } + selectByMouse: true + focus: true + } + } + + // ── 底部工具栏 ──────────────────────────────────── + Rectangle { + Layout.fillWidth: true + height: 40 + color: "transparent" + + RowLayout { + anchors.fill: parent + spacing: 8 + + Item { Layout.fillWidth: true } + + // 取消按钮 + Rectangle { + width: 70; height: 32; radius: 6 + color: cancelMouse.containsMouse ? "#F1F5F9" : "#F8FAFC" + border.color: "#E2E8F0"; border.width: 1 + + Text { anchors.centerIn: parent; text: qsTr("取消"); font.pixelSize: 13; color: "#64748B" } + + MouseArea { + id: cancelMouse + anchors.fill: parent; hoverEnabled: true + onClicked: chatPanel.close() + } + } + + // 发送按钮 + Rectangle { + width: 70; height: 32; radius: 6 + color: sendMouse.pressed ? "#0066cc" : (sendMouse.containsMouse ? "#3399ff" : "#0088ff") + + RowLayout { + anchors.centerIn: parent; spacing: 4 + Image { source: "qrc:/resource/img/lc_send.svg"; width: 14; height: 14 } + Text { text: qsTr("发送"); font.pixelSize: 13; color: "white" } + } + + MouseArea { + id: sendMouse + anchors.fill: parent; hoverEnabled: true + onClicked: { + if (input.text.trim() !== "") { + chatPanel.signalSendPrompt(input.text) + chatPanel.close() + } + } + } + } + } + } + } + } + } +} diff --git a/qml/TZControls/TZComboBox.qml b/qml/TZControls/TZComboBox.qml new file mode 100644 index 0000000..a9c88de --- /dev/null +++ b/qml/TZControls/TZComboBox.qml @@ -0,0 +1,128 @@ +import QtQuick +import QtQuick.Controls.Basic + +ComboBox { + id: control + + property color fillColor: "#F8FAFC" + property color borderColor: "#E2E8F0" + property color activeBorderColor: "#3B82F6" + property color hoverColor: "#F1F5F9" + property color textColor: "#1E293B" + property color dimTextColor: "#64748B" + + width: 120 + height: 36 + font.pixelSize: 13 + font.family: "Microsoft YaHei" + + // 当前选中文字 + contentItem: Text { + leftPadding: 12 + rightPadding: 28 + text: control.displayText + font: control.font + color: control.enabled ? textColor : dimTextColor + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + // 背景 + background: Rectangle { + implicitWidth: control.width + implicitHeight: control.height + color: control.hovered ? hoverColor : fillColor + border.color: control.activeFocus ? activeBorderColor : borderColor + border.width: control.activeFocus ? 2 : 1 + radius: 6 + + Behavior on border.color { ColorAnimation { duration: 150 } } + Behavior on color { ColorAnimation { duration: 120 } } + } + + // 下拉箭头 + indicator: Canvas { + id: canvas + x: control.width - width - 10 + y: (control.height - height) / 2 + width: 10 + height: 6 + contextType: "2d" + + onPaint: { + context.reset() + context.lineWidth = 1.5 + context.strokeStyle = dimTextColor + context.lineCap = "round" + context.lineJoin = "round" + context.moveTo(0, 0) + context.lineTo(width / 2, height) + context.lineTo(width, 0) + context.stroke() + } + } + + // 下拉列表每项 + delegate: ItemDelegate { + width: control.width + height: 36 + + contentItem: Text { + leftPadding: 12 + text: modelData + font: control.font + color: highlighted ? "#3B82F6" : textColor + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + color: highlighted ? "#EFF6FF" : fillColor + } + } + + // 弹出框 + popup: Popup { + y: control.height + 4 + width: control.width + implicitHeight: listview.contentHeight + padding: 4 + + contentItem: ListView { + id: listview + clip: true + model: control.popup.visible ? control.delegateModel : null + currentIndex: control.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator {} + } + + background: Rectangle { + color: "#FFFFFF" + border.color: "#E2E8F0" + border.width: 1 + radius: 8 + + // 顶部细线装饰 + Rectangle { + width: parent.width - 2 + height: 1 + anchors.top: parent.top + anchors.topMargin: 1 + anchors.horizontalCenter: parent.horizontalCenter + color: "#3B82F6" + opacity: 0.4 + } + } + + enter: Transition { + NumberAnimation { property: "opacity"; from: 0.0; to: 1.0; duration: 120 } + NumberAnimation { property: "scale"; from: 0.95; to: 1.0; duration: 120 } + } + exit: Transition { + NumberAnimation { property: "opacity"; from: 1.0; to: 0.0; duration: 80 } + } + } +} + + diff --git a/qml/TZControls/qmldir b/qml/TZControls/qmldir new file mode 100644 index 0000000..7a523c1 --- /dev/null +++ b/qml/TZControls/qmldir @@ -0,0 +1,2 @@ +module TZControls +TZComboBox 1.0 TZComboBox.qml diff --git a/qml/TZGifRecordBar.qml b/qml/TZGifRecordBar.qml new file mode 100644 index 0000000..f5b4003 --- /dev/null +++ b/qml/TZGifRecordBar.qml @@ -0,0 +1,194 @@ +import QtQuick 2.12 +import QtQuick.Layouts 1.12 +import QtQuick.Controls 2.5 + +// GIF 录制控制栏:在截图选区确定后显示 +Rectangle { + id: gifBar + radius: 10 + color: "#FFFFFF" + border.color: "#E2E8F0" + border.width: 1 + layer.enabled: true + + // 外部传入的录制区域 + property rect captureRect: Qt.rect(0, 0, 0, 0) + + signal signalCancel + signal signalRecordFinished + + readonly property bool isRecording: O_GifRecordVM.isRecording + readonly property bool isEncoding: O_GifRecordVM.isEncoding + readonly property int elapsedSecs: O_GifRecordVM.elapsedSecs + readonly property int frameCount: O_GifRecordVM.frameCount + + Connections { + target: O_GifRecordVM + + function onEncodingFinished(savedPath) { + gifBar.signalRecordFinished() + } + + function onEncodingFailed(error) { + console.warn("[GifRecordBar] 录制失败:", error) + gifBar.signalRecordFinished() + } + } + + SequentialAnimation { + id: blinkAnim + running: gifBar.isRecording + loops: Animation.Infinite + PropertyAnimation { target: recDot; property: "opacity"; to: 0.2; duration: 500 } + PropertyAnimation { target: recDot; property: "opacity"; to: 1.0; duration: 500 } + } + + RowLayout { + anchors.fill: parent + anchors.margins: 6 + layoutDirection: Qt.RightToLeft + spacing: 4 + + // 取消按钮 + Rectangle { + width: 32; height: 32; radius: 7 + visible: !gifBar.isEncoding + color: cancelArea.containsMouse ? "#FEE2E2" : "transparent" + Behavior on color { ColorAnimation { duration: 100 } } + Image { + anchors.centerIn: parent + source: "qrc:/resource/img/lc_x.svg" + width: 18; height: 18 + fillMode: Image.PreserveAspectFit + smooth: true + } + MouseArea { + id: cancelArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: { + O_GifRecordVM.cancelRecording() + gifBar.signalCancel() + } + } + } + + // 停止录制按钮 + Rectangle { + width: 32; height: 32; radius: 7 + visible: gifBar.isRecording && !gifBar.isEncoding + color: stopArea.containsMouse ? "#FEF3C7" : "transparent" + Behavior on color { ColorAnimation { duration: 100 } } + Image { + anchors.centerIn: parent + source: "qrc:/resource/img/lc_gif_stop.svg" + width: 18; height: 18 + fillMode: Image.PreserveAspectFit + smooth: true + layer.enabled: true + layer.effect: null + } + MouseArea { + id: stopArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: O_GifRecordVM.stopRecording() + } + } + + Rectangle { + width: 1; height: 20 + visible: gifBar.isRecording && !gifBar.isEncoding + color: "#E2E8F0" + Layout.alignment: Qt.AlignVCenter + } + + // 录制中:时间 + 帧数 + Item { + visible: gifBar.isRecording && !gifBar.isEncoding + Layout.fillWidth: true + height: 32 + implicitWidth: recRow.implicitWidth + 8 + + Row { + id: recRow + anchors.centerIn: parent + spacing: 6 + + Rectangle { + id: recDot + width: 8; height: 8; radius: 4 + color: "#EF4444" + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: { + var m = Math.floor(gifBar.elapsedSecs / 60) + var s = gifBar.elapsedSecs % 60 + return (m < 10 ? "0" + m : m) + ":" + (s < 10 ? "0" + s : s) + } + font.pixelSize: 13 + font.family: "monospace" + color: "#1E293B" + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: gifBar.frameCount + "f" + font.pixelSize: 11 + color: "#94A3B8" + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // 编码中:进度提示 + Item { + visible: gifBar.isEncoding + Layout.fillWidth: true + height: 32 + implicitWidth: encodingRow.implicitWidth + 16 + + Row { + id: encodingRow + anchors.centerIn: parent + spacing: 8 + + Rectangle { + width: 16; height: 16 + color: "transparent" + anchors.verticalCenter: parent.verticalCenter + Rectangle { + width: 16; height: 16 + radius: 8 + color: "transparent" + border.color: "#3B82F6" + border.width: 2 + Rectangle { + width: 6; height: 6 + color: "#3B82F6" + radius: 3 + x: 5; y: -1 + } + RotationAnimation on rotation { + running: gifBar.isEncoding + from: 0; to: 360 + duration: 800 + loops: Animation.Infinite + } + } + } + + Text { + text: qsTr("正在生成 GIF...") + font.pixelSize: 13 + color: "#1E293B" + anchors.verticalCenter: parent.verticalCenter + } + } + } + } +} diff --git a/qml/TZLable.qml b/qml/TZLable.qml new file mode 100644 index 0000000..0863d50 --- /dev/null +++ b/qml/TZLable.qml @@ -0,0 +1,26 @@ +import QtQuick + +// 显示当前截图大小 +Rectangle { + property string lableText: "" + + radius: 6 + color: "#1E293B" + opacity: 0.85 + border.color: "#334155" + border.width: 1 + + implicitWidth: labelContent.implicitWidth + 16 + implicitHeight: labelContent.implicitHeight + 8 + + Text { + id: labelContent + anchors.centerIn: parent + font.pixelSize: 12 + font.family: "Microsoft YaHei" + color: "#F1F5F9" + text: lableText + renderType: Text.NativeRendering + } +} + diff --git a/qml/TZLoadingSpinner.qml b/qml/TZLoadingSpinner.qml new file mode 100644 index 0000000..e4df73f --- /dev/null +++ b/qml/TZLoadingSpinner.qml @@ -0,0 +1,52 @@ +import QtQuick + +// ── AI 加载动画层 ───────────────────────────────────── +Rectangle { + + width: 100 + height: 100 + radius: 2 + color: "#99000000" // 半透明黑色遮罩 + + + // 旋转圆弧 + Item { + id: spinnerRoot + anchors.centerIn: parent + width: 48 + height: 48 + + RotationAnimator { + target: spinnerRoot + from: 0; to: 360 + duration: 900 + loops: Animation.Infinite + running: O_AIViewModel.isLoading + } + + // 四个小圆点模拟旋转圆弧 + Repeater { + model: 8 + Rectangle { + width: 6 + height: 6 + radius: 3 + color: "white" + opacity: (index + 1) / 8 + x: spinnerRoot.width / 2 + 18 * Math.cos(index * Math.PI / 4) - width / 2 + y: spinnerRoot.height / 2 + 18 * Math.sin(index * Math.PI / 4) - height / 2 + } + } + } + + // 提示文字 + Text { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: spinnerRoot.bottom + anchors.topMargin: 10 + text: "AI 生成中..." + color: "white" + font.pixelSize: 13 + } +} + diff --git a/qml/TZMagnifyingGlass.qml b/qml/TZMagnifyingGlass.qml new file mode 100644 index 0000000..dc19798 --- /dev/null +++ b/qml/TZMagnifyingGlass.qml @@ -0,0 +1,114 @@ +import QtQuick +import QtQuick.Window + +// ── 放大镜 ─────────────────────────────────────────────── +Rectangle { + id: magnifier + width: 130 + height: 170 // 额外 40px 供颜色信息显示 + z: 10 + color: "transparent" + visible: root.visibility === Window.FullScreen + + // 跟随鼠标移动,始终显示(不随截图状态切换) + property int magnifierMouseX: 0 + property int magnifierMouseY: 0 + property color magnifierPixelColor: "#000000" + + // 跟随鼠标,偏移 +15px,防止被遮挡;超出屏幕时翻转 + x: { + var px = magnifierMouseX + 15 + if (px + width > root.width) px = magnifierMouseX - width - 15 + return px + } + y: { + var py = magnifierMouseY + 15 + if (py + height > root.height) py = magnifierMouseY - height - 15 + return py + } + + // 外框 + Rectangle { + id: magnifierFrame + anchors.top: parent.top + width: 130 + height: 130 + radius: 4 + color: "#1a1a1a" + border.color: "#ffffff" + border.width: 1 + clip: true + + // 放大图像(来自 ImageProvider) + Image { + id: magnifierImage + anchors.centerIn: parent + width: 110 + height: 110 + // cache: false 确保每次坐标变化时重新请求图像 + cache: false + // source 用时间戳 +坐标,强制 QML 重新加载 + source: root.visibility === Window.FullScreen + ? ("image://magnifier/" + magnifierMouseX + "," + magnifierMouseY + "?t=" + Date.now()) + : "" + fillMode: Image.Stretch + } + + // 中心像素颜色色块(8×8,叠在图像中心上方,不遮挡准线) + Rectangle { + anchors.centerIn: parent + width: 8 + height: 8 + color: magnifierPixelColor + border.color: "#ffffff" + border.width: 1 + visible: false // 色块已在 RGB 文本行体现,避免遮挡准线 + } + } + + // 颜色信息栏 + Rectangle { + id: colorInfoBar + anchors.top: magnifierFrame.bottom + anchors.topMargin: 2 + width: 130 + height: 38 + radius: 4 + color: "#cc1a1a1a" + border.color: "#555555" + border.width: 1 + + Row { + anchors.centerIn: parent + spacing: 6 + + // 色块 + Rectangle { + width: 16 + height: 16 + radius: 2 + color: magnifierPixelColor + border.color: "#ffffff" + border.width: 1 + anchors.verticalCenter: parent.verticalCenter + } + + // RGB 文本 + Text { + id: rgbText + anchors.verticalCenter: parent.verticalCenter + color: "#ffffff" + font.pixelSize: 11 + font.family: "monospace" + text: { + var c = magnifierPixelColor + var r = Math.round(c.r * 255) + var g = Math.round(c.g * 255) + var b = Math.round(c.b * 255) + return "R:" + r + " G:" + g + " B:" + b + } + } + } + } +} +// ── 放大镜 end ─────────────────────────────────────────── diff --git a/qml/TZMarkToolInfo.qml b/qml/TZMarkToolInfo.qml new file mode 100644 index 0000000..5c35099 --- /dev/null +++ b/qml/TZMarkToolInfo.qml @@ -0,0 +1,257 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +Rectangle { + id: panel + + property color currentColor: "#F43F5E" + property int currentSize: 6 + property bool showColorRow: true + property string toolName: "" + + property string annotationText: qsTr("文本") + property bool textBackgroundEnabled: true + property int mosaicBlurLevel: 2 + property bool numberAutoIncrement: true + property int numberValue: 1 + + width: 260 + height: toolName === "text" ? 128 + : (toolName === "number" ? 132 + : ((toolName === "mosaic") ? 92 : (showColorRow ? 84 : 50))) + radius: 10 + color: "#FFFFFF" + border.color: "#E2E8F0" + border.width: 1 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + + RowLayout { + spacing: 6 + Layout.fillWidth: true + visible: panel.showColorRow + height: panel.showColorRow ? implicitHeight : 0 + + Repeater { + model: ["#F43F5E","#EF4444","#F97316","#EAB308", + "#22C55E","#3B82F6","#8B5CF6","#000000","#FFFFFF"] + delegate: Rectangle { + width: 18; height: 18; radius: 9 + color: modelData + border.color: panel.currentColor === modelData ? "#3B82F6" : "#CBD5E1" + border.width: panel.currentColor === modelData ? 2 : 1 + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onPressed: panel.currentColor = modelData + } + } + } + } + + RowLayout { + spacing: 6 + Layout.fillWidth: true + + Repeater { + model: [ + { size: 2, label: qsTr("细") }, + { size: 4, label: qsTr("中") }, + { size: 6, label: qsTr("粗") }, + { size: 10, label: qsTr("特粗") } + ] + delegate: Rectangle { + width: 48; height: 28 + radius: 6 + color: panel.currentSize === modelData.size ? "#EFF6FF" : (sizeArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: panel.currentSize === modelData.size ? "#3B82F6" : "#E2E8F0" + border.width: 1 + + Column { + anchors.centerIn: parent + spacing: 3 + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + width: 24 + height: Math.min(modelData.size, 6) + radius: height / 2 + color: panel.currentSize === modelData.size ? "#3B82F6" : "#94A3B8" + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: modelData.label + font.pixelSize: 10 + color: panel.currentSize === modelData.size ? "#3B82F6" : "#64748B" + } + } + + MouseArea { + id: sizeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: panel.currentSize = modelData.size + } + } + } + } + + RowLayout { + visible: panel.toolName === "text" + Layout.fillWidth: true + spacing: 8 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 34 + radius: 6 + color: "#F8FAFC" + border.color: textInput.activeFocus ? "#3B82F6" : "#E2E8F0" + border.width: 1 + + TextInput { + id: textInput + anchors.fill: parent + anchors.margins: 8 + verticalAlignment: TextInput.AlignVCenter + color: "#0F172A" + font.pixelSize: 13 + selectByMouse: true + text: panel.annotationText + onTextChanged: panel.annotationText = text + } + + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 8 + visible: textInput.text.length === 0 && !textInput.activeFocus + text: qsTr("输入文字") + color: "#94A3B8" + font.pixelSize: 13 + } + } + + CheckBox { + text: qsTr("背景") + checked: panel.textBackgroundEnabled + onToggled: panel.textBackgroundEnabled = checked + } + } + + RowLayout { + visible: panel.toolName === "mosaic" + Layout.fillWidth: true + spacing: 8 + + Text { + text: qsTr("模糊") + color: "#334155" + } + + Slider { + Layout.fillWidth: true + from: 1 + to: 4 + stepSize: 1 + value: panel.mosaicBlurLevel + onMoved: panel.mosaicBlurLevel = Math.round(value) + } + + Text { + text: panel.mosaicBlurLevel + color: "#64748B" + } + } + + ColumnLayout { + visible: panel.toolName === "number" + Layout.fillWidth: true + spacing: 8 + + CheckBox { + text: qsTr("自动递增") + checked: panel.numberAutoIncrement + onToggled: panel.numberAutoIncrement = checked + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: qsTr("数字") + color: "#334155" + } + + Rectangle { + width: 28 + height: 28 + radius: 6 + color: minusArea.containsMouse ? "#F1F5F9" : "transparent" + border.color: "#CBD5E1" + border.width: 1 + + Text { + anchors.centerIn: parent + text: "-" + color: "#334155" + font.pixelSize: 16 + } + + MouseArea { + id: minusArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: panel.numberValue = Math.max(1, panel.numberValue - 1) + } + } + + Rectangle { + Layout.fillWidth: true + height: 28 + radius: 6 + color: "#F8FAFC" + border.color: "#E2E8F0" + border.width: 1 + + Text { + anchors.centerIn: parent + text: panel.numberValue + color: "#0F172A" + font.pixelSize: 13 + } + } + + Rectangle { + width: 28 + height: 28 + radius: 6 + color: plusArea.containsMouse ? "#F1F5F9" : "transparent" + border.color: "#CBD5E1" + border.width: 1 + + Text { + anchors.centerIn: parent + text: "+" + color: "#334155" + font.pixelSize: 16 + } + + MouseArea { + id: plusArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: panel.numberValue = Math.min(9999, panel.numberValue + 1) + } + } + } + } + } +} diff --git a/qml/TZOcrResult.qml b/qml/TZOcrResult.qml new file mode 100644 index 0000000..624766b --- /dev/null +++ b/qml/TZOcrResult.qml @@ -0,0 +1,328 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Controls +import QtQuick.Layouts + +// TZOcrResult +// ----------- +// 无边框悬浮窗,展示 OCR 识别结果。 +// 由 TZWindow.qml 通过 Qt.createComponent 动态创建并显示。 + +Window { + id: ocrWin + flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool + color: "transparent" + width: 420 + height: 320 + + // 初始居中(创建后由调用方设置位置,此处给默认值) + x: (Screen.width - width) / 2 + y: (Screen.height - height) / 2 + + Component.onCompleted: { + ocrWin.raise() + ocrWin.requestActivate() + } + + Shortcut { + sequence: "Esc" + onActivated: ocrWin.close() + } + + // ── 阴影容器 ────────────────────────────────────────── + Rectangle { + id: shadow + anchors.fill: parent + anchors.margins: 10 + radius: 12 + color: "white" + layer.enabled: true + border.color: "#D1D5DB" + border.width: 1 + + // ── 标题栏 ───────────────────────────────────────── + Rectangle { + id: titleBar + anchors.top: shadow.top + anchors.left: shadow.left + anchors.right: shadow.right + height: 40 + radius: 12 + color: "#F8FAFC" + + // 圆角只在上半部分(下方盖住) + Rectangle { + anchors.bottom: titleBar.bottom + anchors.left: titleBar.left + anchors.right: titleBar.right + height: titleBar.radius + color: titleBar.color + } + + // 标题文字 + Text { + anchors.verticalCenter: titleBar.verticalCenter + anchors.left: titleBar.left + anchors.leftMargin: 14 + text: qsTr("OCR 识别结果") + font.pixelSize: 14 + font.weight: Font.Medium + color: "#1E293B" + } + + // 关闭按钮 + Rectangle { + id: closeBtn + anchors.right: titleBar.right + anchors.rightMargin: 8 + anchors.verticalCenter: titleBar.verticalCenter + width: 26; height: 26; radius: 5 + color: closeArea.containsMouse ? "#FEE2E2" : "transparent" + Behavior on color { ColorAnimation { duration: 80 } } + + Text { + anchors.centerIn: parent + text: "✕" + color: closeArea.containsMouse ? "#DC2626" : "#94A3B8" + font.pixelSize: 13 + } + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: ocrWin.close() + } + } + + // 拖动标题栏移动窗口 + MouseArea { + anchors.fill: titleBar + anchors.rightMargin: 40 + property point pressPos + onPressed: function(mouse) { + pressPos = Qt.point(mouse.x, mouse.y) + } + onPositionChanged: function(mouse) { + if (pressed) { + ocrWin.x += mouse.x - pressPos.x + ocrWin.y += mouse.y - pressPos.y + } + } + } + } + + // ── 分割线 ───────────────────────────────────────── + Rectangle { + id: divider + anchors.top: titleBar.bottom + anchors.left: shadow.left + anchors.right: shadow.right + height: 1 + color: "#E2E8F0" + } + + // ── 内容区 ───────────────────────────────────────── + Item { + anchors.top: divider.bottom + anchors.left: shadow.left + anchors.right: shadow.right + anchors.bottom: bottomBar.top + + // Loading 遮罩 + Rectangle { + anchors.fill: parent + color: "#F8FAFC" + visible: O_OcrVM.isRecognizing + radius: 6 + + Item { + id: spinnerRoot + anchors.centerIn: parent + width: 48; height: 48 + + RotationAnimator { + target: spinnerRoot + from: 0; to: 360 + duration: 900 + loops: Animation.Infinite + running: O_OcrVM.isRecognizing + } + Repeater { + model: 8 + Rectangle { + width: 6; height: 6; radius: 3 + color: "#3B82F6" + opacity: (index + 1) / 8 + x: spinnerRoot.width / 2 + 18 * Math.cos(index * Math.PI / 4) - width / 2 + y: spinnerRoot.height / 2 + 18 * Math.sin(index * Math.PI / 4) - height / 2 + } + } + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: spinnerRoot.bottom + anchors.topMargin: 12 + text: qsTr("正在识别中…") + color: "#64748B" + font.pixelSize: 13 + } + } + + // 文本区域(识别完成后显示) + ScrollView { + anchors.fill: parent + anchors.margins: 8 + visible: !O_OcrVM.isRecognizing + clip: true + + TextArea { + id: resultArea + text: O_OcrVM.resultText + readOnly: false // 允许用户二次编辑 + wrapMode: TextEdit.Wrap + font.pixelSize: 14 + color: "#1E293B" + background: null + padding: 4 + selectByMouse: true + placeholderText: qsTr("识别结果将显示在这里…") + placeholderTextColor: "#94A3B8" + } + } + } + + // ── 底部操作栏 ───────────────────────────────────── + Rectangle { + id: bottomBar + anchors.bottom: shadow.bottom + anchors.left: shadow.left + anchors.right: shadow.right + height: 44 + radius: 12 + color: "#F8FAFC" + + // 圆角只在下半部分 + Rectangle { + anchors.top: bottomBar.top + anchors.left: bottomBar.left + anchors.right: bottomBar.right + height: bottomBar.radius + color: bottomBar.color + } + + // 分割线 + Rectangle { + anchors.top: bottomBar.top + anchors.left: bottomBar.left + anchors.right: bottomBar.right + height: 1 + color: "#E2E8F0" + } + + Row { + anchors.centerIn: parent + spacing: 10 + + // 一键复制 + Rectangle { + id: copyBtn + width: 100; height: 30; radius: 6 + color: copyArea.containsMouse ? "#DBEAFE" : "#EFF6FF" + border.color: "#3B82F6" + border.width: 1 + Behavior on color { ColorAnimation { duration: 80 } } + + Text { + anchors.centerIn: parent + text: qsTr("复制全部") + color: "#3B82F6" + font.pixelSize: 13 + } + MouseArea { + id: copyArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + O_OcrVM.copyResultToClipboard() + copyFeedbackTimer.start() + } + } + } + + // 关闭 + Rectangle { + width: 70; height: 30; radius: 6 + color: closeBtn2Area.containsMouse ? "#F1F5F9" : "transparent" + border.color: "#CBD5E1" + border.width: 1 + Behavior on color { ColorAnimation { duration: 80 } } + + Text { + anchors.centerIn: parent + text: qsTr("关闭") + color: "#64748B" + font.pixelSize: 13 + } + MouseArea { + id: closeBtn2Area + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: ocrWin.close() + } + } + } + } + + // 复制成功短暂提示 + Rectangle { + id: copyFeedback + anchors.horizontalCenter: shadow.horizontalCenter + anchors.bottom: bottomBar.top + anchors.bottomMargin: 6 + width: 100; height: 28; radius: 6 + color: "#22C55E" + visible: false + opacity: 0 + + Text { + anchors.centerIn: parent + text: qsTr("已复制!") + color: "white" + font.pixelSize: 12 + } + + Timer { + id: copyFeedbackTimer + interval: 1200 + onTriggered: copyFeedback.visible = false + } + + onVisibleChanged: { + if (visible) opacity = 1 + } + + Behavior on opacity { + NumberAnimation { duration: 300 } + } + } + + Connections { + target: copyFeedbackTimer + function onTriggered() { + copyFeedback.opacity = 0 + } + } + } + + // 启动计时后立即显示反馈 + Connections { + target: copyArea + function onClicked() { + copyFeedback.visible = true + } + } +} diff --git a/qml/TZResizeHandle.qml b/qml/TZResizeHandle.qml new file mode 100644 index 0000000..91c8ab1 --- /dev/null +++ b/qml/TZResizeHandle.qml @@ -0,0 +1,14 @@ +import QtQuick + +Rectangle { +// id:leftTop +// x: screenshotArea.x - handleSize/2 +// y: screenshotArea.y - handleSize/2 + property int handleSize: 8 + width: handleSize + height: handleSize + radius: handleSize/2 + color: "#0088FF" + border.color: "white" + border.width: 1 +} diff --git a/qml/TZResizeHandleGroup.qml b/qml/TZResizeHandleGroup.qml new file mode 100644 index 0000000..088fe77 --- /dev/null +++ b/qml/TZResizeHandleGroup.qml @@ -0,0 +1,296 @@ +import QtQuick +import QtQuick.Window + + +Rectangle{ + id:handleGroup + property rect area: Qt.rect(0,0,0,0) + property rect initialArea: Qt.rect(0,0,0,0) + property var mousePointStart: Qt.point(0,0) + x:area.x + y:area.y + width: area.width + height: area.height + color: "transparent" + //左上角控制点 + TZResizeHandle + { + id:leftTop + x: - handleSize/2 + y: - handleSize/2 + // 仅当选区宽度/高度非零时显示 + visible: area.width !== 0 || area.height !== 0 + MouseArea { + anchors.fill: parent + cursorShape: Qt.SizeFDiagCursor + onPressed: { + let globalPos = leftTop.mapToItem(null, mouseX, mouseY); + mousePointStart.x = globalPos.x + mousePointStart.y = globalPos.y + initialArea = area + } + + onReleased: { + // 重置所有临时变量 + mousePointStart = Qt.point(0,0) + initialArea = area + } + + onPositionChanged: { + if (pressed) { + let globalPos = leftTop.mapToItem(null, mouseX, mouseY); + let dx = globalPos.x - mousePointStart.x + let dy = globalPos.y - mousePointStart.y + area.x = initialArea.x + dx + area.y = initialArea.y + dy + area.width = initialArea.width - dx + area.height = initialArea.height - dy + } + } + } + } + + //正上方控制点 + TZResizeHandle + { + id: top + x: (area.width - handleSize/2)/2 + y: -handleSize/2 + // 仅当选区宽度/高度非零时显示 + visible: area.width !== 0 || area.height !== 0 + MouseArea { + anchors.fill: parent + cursorShape: Qt.SizeVerCursor + onPressed: { + let globalPos = top.mapToItem(null, mouseX, mouseY); + mousePointStart.y = globalPos.y + initialArea = area + } + + onReleased: { + // 重置所有临时变量 + mousePointStart = Qt.point(0,0) + initialArea = area + } + + onPositionChanged: { + if (pressed) { + let globalPos = top.mapToItem(null, mouseX, mouseY); + let dy = globalPos.y - mousePointStart.y + area.y = initialArea.y + dy + area.height = initialArea.height - dy + } + } + } + } + + //右上角控制点 + TZResizeHandle + { + id:rightTop + x: area.width - handleSize/2 + y: -handleSize/2 + visible: area.width !== 0 || area.height !== 0 + MouseArea { + anchors.fill: parent + cursorShape: Qt.SizeBDiagCursor + onPressed: { + let globalPos = rightTop.mapToItem(null, mouseX, mouseY); + mousePointStart.x = globalPos.x + mousePointStart.y = globalPos.y + initialArea = area + } + + onReleased: { + mousePointStart = Qt.point(0,0) + initialArea = area + } + + onPositionChanged: { + if (pressed) { + let globalPos = rightTop.mapToItem(null, mouseX, mouseY); + let dx = globalPos.x - mousePointStart.x + let dy = globalPos.y - mousePointStart.y + area.y = initialArea.y + dy + area.width = initialArea.width + dx + area.height = initialArea.height - dy + } + } + } + + + } + + //右边控制点 + TZResizeHandle + { + id: right + x: area.width - handleSize/2 + y: (area.height -handleSize/2)/2 + visible: area.width !== 0 || area.height !== 0 + MouseArea { + anchors.fill: parent + cursorShape: Qt.SizeHorCursor + onPressed: { + let globalPos = right.mapToItem(null, mouseX, mouseY); + mousePointStart.x = globalPos.x + initialArea = area + } + + onReleased: { + mousePointStart = Qt.point(0,0) + initialArea = area + } + + onPositionChanged: { + if (pressed) { + let globalPos = right.mapToItem(null, mouseX, mouseY); + let dx = globalPos.x - mousePointStart.x + area.width = initialArea.width + dx + } + } + } + + + } + + //右下角控制点 + TZResizeHandle + { + id:rightButtom + x: area.width - handleSize/2 + y: area.height - handleSize/2 + visible: area.width !== 0 || area.height !== 0 + z:2 + MouseArea { + anchors.fill: parent + cursorShape: Qt.SizeFDiagCursor + onPressed: { + let globalPos = rightButtom.mapToItem(null, mouseX, mouseY); + mousePointStart.x = globalPos.x + mousePointStart.y = globalPos.y + initialArea = area + } + + onReleased: { + mousePointStart = Qt.point(0,0) + initialArea = area + } + + onPositionChanged: { + if (pressed) { + let globalPos = rightButtom.mapToItem(null, mouseX, mouseY); + let dx = globalPos.x - mousePointStart.x + let dy = globalPos.y - mousePointStart.y + area.width = initialArea.width + dx + area.height = initialArea.height + dy + } + } + } + } + + //正下方控制点 + TZResizeHandle + { + id:buttom + x: (area.width - handleSize/2)/2 + y: area.height - handleSize/2 + visible: area.width !== 0 || area.height !== 0 + z:2 + MouseArea { + anchors.fill: parent + cursorShape: Qt.SizeVerCursor + onPressed: { + let globalPos = buttom.mapToItem(null, mouseX, mouseY); + mousePointStart.y = globalPos.y + initialArea = area + } + + onReleased: { + mousePointStart = Qt.point(0,0) + initialArea = area + } + + onPositionChanged: { + if (pressed) { + let globalPos = buttom.mapToItem(null, mouseX, mouseY); + let dy = globalPos.y - mousePointStart.y + area.height = initialArea.height + dy + } + } + } + } + + //左下角控制点 + TZResizeHandle + { + id:leftBottom + x: -handleSize/2 + y: area.height - handleSize/2 + visible: area.width !== 0 || area.height !== 0 + z:2 + MouseArea { + anchors.fill: parent + cursorShape: Qt.SizeBDiagCursor + onPressed: { + let globalPos = leftBottom.mapToItem(null, mouseX, mouseY); + mousePointStart.x = globalPos.x + mousePointStart.y = globalPos.y + initialArea = area + } + + onReleased: { + mousePointStart = Qt.point(0,0) + initialArea = area + } + + onPositionChanged: { + if (pressed) { + let globalPos = leftBottom.mapToItem(null, mouseX, mouseY); + let dx = globalPos.x - mousePointStart.x + let dy = globalPos.y - mousePointStart.y + area.x = initialArea.x + dx + area.width = initialArea.width - dx + area.height = initialArea.height + dy + } + } + } + + } + + //左边控制点 + TZResizeHandle + { + id:left + x: -handleSize/2 + y: (area.height - handleSize/2)/2 + visible: area.width !== 0 || area.height !== 0 + z:2 + MouseArea { + anchors.fill: parent + cursorShape: Qt.SizeHorCursor + onPressed: { + let globalPos = left.mapToItem(null, mouseX, mouseY); + mousePointStart.x = globalPos.x + initialArea = area + } + + onReleased: { + mousePointStart = Qt.point(0,0) + initialArea = area + } + + onPositionChanged: { + if (pressed) { + let globalPos = left.mapToItem(null, mouseX, mouseY); + let dx = globalPos.x - mousePointStart.x + area.x = initialArea.x + dx + area.width = initialArea.width - dx + } + } + } + + } + +} + diff --git a/qml/TZScreenOverlay.qml b/qml/TZScreenOverlay.qml new file mode 100644 index 0000000..8230dd1 --- /dev/null +++ b/qml/TZScreenOverlay.qml @@ -0,0 +1,101 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Shapes + +// TZScreenOverlay — 辅屏截图覆盖窗口 +// +// 每块非主屏创建一个此窗口,覆盖该屏全部区域。 +// 职责: +// 1. 显示半透明蒙层 + 选区镂空 + 窗口识别高亮 +// 2. 将本地鼠标坐标转为虚拟桌面绝对坐标,委托给主窗口(delegate)处理 +// +// 注入属性: +// delegate — 主窗口(root / TZWindow),提供共享截图状态和处理函数 +// screenVX — 本屏 virtualX(虚拟桌面绝对 X) +// screenVY — 本屏 virtualY(虚拟桌面绝对 Y) + +Window { + id: overlay + color: "transparent" + flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint + visible: delegate ? (!delegate.gifRecordMode && delegate.captureUiReady) : true + + property var delegate: null + property int screenVX: 0 + property int screenVY: 0 + + // 从 delegate 读取共享状态 + property rect screenshotArea: delegate ? delegate.screenshotArea : Qt.rect(0,0,0,0) + property rect highlightRect: delegate ? delegate.highlightRect : Qt.rect(0,0,0,0) + + function toAbsX(lx) { return lx + screenVX } + function toAbsY(ly) { return ly + screenVY } + + // 绝对坐标 → 本窗口本地坐标(供绘制用) + readonly property rect localArea: Qt.rect( + screenshotArea.x - screenVX, screenshotArea.y - screenVY, + screenshotArea.width, screenshotArea.height) + readonly property rect localHighlight: Qt.rect( + highlightRect.x - screenVX, highlightRect.y - screenVY, + highlightRect.width, highlightRect.height) + + // ── 半透明蒙层 + 选区镂空 ───────────────────────────── + Shape { + anchors.fill: parent + ShapePath { + strokeWidth: -1 + fillColor: "#99000000" + startX: 0; startY: 0 + PathLine { x: overlay.width; y: 0 } + PathLine { x: overlay.width; y: overlay.height } + PathLine { x: 0; y: overlay.height } + PathLine { x: 0; y: 0 } + PathMove { x: overlay.localArea.x; y: overlay.localArea.y } + PathLine { x: overlay.localArea.x + overlay.localArea.width; y: overlay.localArea.y } + PathLine { x: overlay.localArea.x + overlay.localArea.width; y: overlay.localArea.y + overlay.localArea.height } + PathLine { x: overlay.localArea.x; y: overlay.localArea.y + overlay.localArea.height } + PathLine { x: overlay.localArea.x; y: overlay.localArea.y } + } + } + + // ── 窗口识别高亮框 ──────────────────────────────────── + Rectangle { + visible: screenshotArea.width === 0 && screenshotArea.height === 0 + && highlightRect.width > 0 && highlightRect.height > 0 + x: overlay.localHighlight.x + y: overlay.localHighlight.y + width: overlay.localHighlight.width + height: overlay.localHighlight.height + color: "transparent" + border.color: "#3B82F6" + border.width: 2 + z: 5 + Rectangle { anchors.fill: parent; color: "#153B82F6" } + } + + // ── 鼠标输入:全部转为绝对坐标,委托给主窗口处理 ───── + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.CrossCursor + + onPressed: function(mouse) { + if (!delegate) return + var ax = toAbsX(mouseX), ay = toAbsY(mouseY) + delegate.handleMousePressed(ax, ay) + } + + onPositionChanged: function(mouse) { + if (!delegate) return + var ax = toAbsX(mouseX), ay = toAbsY(mouseY) + delegate.handleMouseMoved(ax, ay, pressed) + } + + onReleased: function(mouse) { + if (!delegate) return + delegate.handleMouseReleased() + } + + onWheel: function(wheel) { wheel.accepted = true } + } +} diff --git a/qml/TZStickyWindow.qml b/qml/TZStickyWindow.qml new file mode 100644 index 0000000..8954eb4 --- /dev/null +++ b/qml/TZStickyWindow.qml @@ -0,0 +1,678 @@ +import QtQuick +import QtQuick.Window +import Qt.labs.platform 1.0 as Platform +import QtQuick.Effects +import QtQuick.Controls.Basic +import QtCore +import CustomComponents 1.0 + +Window { + id: stickyWin + flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool + color: "transparent" + + property string imageUrl: "" + property rect imgRect: Qt.rect(0, 0, 0, 0) + property bool contentActive: true + property bool cleanedUp: false + + property real imgOpacity: 1.0 + property string activeTool: "" + property bool toolbarVisible: true + property string toolbarHoverTipText: "" + property real toolbarHoverTipCenterX: 0 + property bool contextMenuOpen: false + + property int imageRevision: 0 + property int imageBaseW: Math.max(1, Math.abs(imgRect.width)) + property int imageBaseH: Math.max(1, Math.abs(imgRect.height)) + property real zoomFactor: 1.0 + + readonly property int toolbarHeight: 40 + readonly property int toolbarGap: 8 + readonly property int toolbarTipGap: 6 + readonly property int toolbarTipHeight: 24 + readonly property int shadowRadius: 12 + readonly property int shadowOffset: 4 + + readonly property int displayW: Math.max(1, Math.round(imageBaseW * zoomFactor)) + readonly property int displayH: Math.max(1, Math.round(imageBaseH * zoomFactor)) + + readonly property int stickyShapeType: { + switch (activeTool) { + case "pencil": return 0 + case "rect": return 1 + case "circle": return 2 + case "arrow": return 3 + case "mosaic": return 4 + default: return 1 + } + } + + width: displayW + shadowRadius * 2 + height: displayH + shadowRadius * 2 + shadowOffset + toolbarGap + toolbarHeight + toolbarTipGap + toolbarTipHeight + 4 + x: (imgRect.width >= 0 ? imgRect.x : imgRect.x + imgRect.width) - shadowRadius + y: (imgRect.height >= 0 ? imgRect.y : imgRect.y + imgRect.height) - shadowRadius + + Settings { + id: menuPrefs + category: "sticky_menu" + property bool pinSaveMerged: true + property bool pinCopy: true + property bool pinAi: false + property bool pinToolbar: false + property bool pinClose: false + } + + function setActiveTool(name) { + if (activeTool === name) + activeTool = "" + else + activeTool = name + } + + function showToolbarTip(text, item) { + toolbarHoverTipText = text + if (item) { + var p = stickyToolbar.mapFromItem(item, item.width / 2, item.height) + toolbarHoverTipCenterX = p.x + } else { + toolbarHoverTipCenterX = stickyToolbar.width / 2 + } + } + + function hideToolbarTip(text) { + if (text === undefined || toolbarHoverTipText === text) + toolbarHoverTipText = "" + } + + function displaySourceUrl() { + if (typeof stickyWin.imageUrl !== "string" || stickyWin.imageUrl.length === 0) + return "" + return stickyWin.imageUrl + "?rev=" + stickyWin.imageRevision + } + + function setOpacityValue(v) { + stickyWin.imgOpacity = Math.max(0.1, Math.min(1.0, v)) + } + + function setZoomValue(v) { + zoomFactor = Math.max(0.3, Math.min(4.0, v)) + } + + function resetDisplaySize() { + zoomFactor = 1.0 + } + + function updateImageMetricsFromStore() { + var img = O_StickyViewModel.getImageByUrl(stickyWin.imageUrl) + if (img && img.width > 0 && img.height > 0) { + imageBaseW = img.width + imageBaseH = img.height + stickyPaintBoard.setBackgroundImg(img) + } else { + syncPaintboardBackgroundFromDisplay() + } + } + + function syncPaintboardBackgroundFromDisplay() { + if (!stickyWin.visible || !stickyWin.contentActive) + return + if (stickyImage.status !== Image.Ready) + return + stickyImage.grabToImage(function(result) { + if (!result || !result.image || result.image.width <= 0 || result.image.height <= 0) + return + if (imageBaseW <= 1 || imageBaseH <= 1) { + imageBaseW = result.image.width + imageBaseH = result.image.height + } + stickyPaintBoard.setBackgroundImg(result.image) + }, Qt.size(Math.max(1, imageBaseW), Math.max(1, imageBaseH))) + } + + function refreshAfterImageChanged() { + imageRevision += 1 + stickyPaintBoard.reset() + updateImageMetricsFromStore() + } + + function overwriteWithEdits() { + var layer = stickyPaintBoard.renderToImage() + var ok = O_StickyViewModel.overwriteWithAnnotations(stickyWin.imageUrl, layer) + if (ok) + refreshAfterImageChanged() + } + + function rotateCurrent(degrees) { + if (O_StickyViewModel.rotateImage(stickyWin.imageUrl, degrees)) + refreshAfterImageChanged() + } + + function mirrorCurrent() { + if (O_StickyViewModel.mirrorImage(stickyWin.imageUrl, true, false)) + refreshAfterImageChanged() + } + + function saveMergedTo(pathUrl) { + var layer = stickyPaintBoard.renderToImage() + O_StickyViewModel.saveMergedImage(stickyWin.imageUrl, layer, pathUrl) + } + + function completeEditsAndCopy() { + var layer = stickyPaintBoard.renderToImage() + if (layer && layer.width > 0 && layer.height > 0) + overwriteWithEdits() + O_StickyViewModel.copyImageToClipboard(stickyWin.imageUrl) + stickyWin.activeTool = "" + stickyWin.toolbarVisible = false + } + + function cleanup() { + if (cleanedUp) + return + cleanedUp = true + + stickyWin.visible = false + contentActive = false + stickyWin.releaseResources() + O_StickyViewModel.releaseImage(imageUrl) + imageUrl = "" + } + + Component.onCompleted: { + updateImageMetricsFromStore() + stickyWin.raise() + stickyWin.requestActivate() + } + + Shortcut { + sequence: "Esc" + onActivated: { + if (stickyWin.activeTool !== "") { + stickyWin.activeTool = "" + return + } + if (stickyWin.toolbarVisible) { + stickyWin.toolbarVisible = false + return + } + stickyWin.close() + } + } + + onImageUrlChanged: { + imageRevision = 0 + resetDisplaySize() + refreshAfterImageChanged() + } + + onActiveToolChanged: { + if (activeTool === "mosaic") + updateImageMetricsFromStore() + } + + Component.onDestruction: cleanup() + + onClosing: function(close) { + close.accepted = true + chatPanel.close() + cleanup() + Qt.callLater(function() { stickyWin.destroy() }) + } + + Rectangle { + id: imgContainer + visible: stickyWin.contentActive + z: 10 + x: stickyWin.shadowRadius + y: stickyWin.shadowRadius + stickyWin.shadowOffset / 2 + width: stickyWin.displayW + height: stickyWin.displayH + radius: 2 + clip: true + color: "transparent" + + Item { + id: visualStage + anchors.centerIn: parent + width: stickyWin.imageBaseW + height: stickyWin.imageBaseH + scale: stickyWin.zoomFactor + + Image { + id: stickyImage + anchors.fill: parent + source: displaySourceUrl() + fillMode: Image.Stretch + opacity: stickyWin.imgOpacity + cache: false + onStatusChanged: { + if (status === Image.Ready && stickyWin.activeTool === "mosaic" && !stickyPaintBoard.hasBackgroundImg()) + stickyWin.syncPaintboardBackgroundFromDisplay() + } + } + + PaintBoard { + id: stickyPaintBoard + anchors.fill: parent + z: 20 + visible: stickyWin.contentActive + enabled: stickyWin.activeTool !== "" + shapeType: stickyWin.stickyShapeType + penColor: stickyMarkTool.currentColor + penSize: stickyMarkTool.currentSize + mosaicBlurLevel: stickyMarkTool.mosaicBlurLevel + } + } + } + + MultiEffect { + visible: stickyWin.contentActive + z: 5 + source: stickyImage + anchors.fill: imgContainer + shadowEnabled: true + shadowColor: "#55000000" + shadowBlur: stickyWin.shadowRadius / 20.0 + shadowVerticalOffset: stickyWin.shadowOffset + shadowHorizontalOffset: 0 + opacity: stickyWin.imgOpacity + } + + TZLoadingSpinner { + anchors.centerIn: imgContainer + width: stickyWin.displayW + height: stickyWin.displayH + visible: O_AIViewModel.isLoading && stickyWin.contentActive + } + + TZMarkToolInfo { + id: stickyMarkTool + z: 32 + visible: stickyWin.toolbarVisible && stickyWin.activeTool !== "" + toolName: stickyWin.activeTool + showColorRow: stickyWin.activeTool !== "mosaic" + x: Math.max(8, stickyToolbar.x) + y: stickyToolbar.y - height - 6 + } + + Rectangle { + id: stickyToolbar + z: 30 + visible: stickyWin.toolbarVisible + width: Math.min(stickyWin.width - 16, Math.max(560, stickyRow.implicitWidth + 16)) + height: stickyWin.toolbarHeight + radius: 10 + color: "#FFFFFF" + border.color: "#E2E8F0" + border.width: 1 + x: Math.max(8, Math.min(stickyWin.width - width - 8, stickyWin.shadowRadius + (stickyWin.displayW - width) / 2)) + y: stickyWin.shadowRadius + stickyWin.shadowOffset / 2 + stickyWin.displayH + stickyWin.toolbarGap + + Row { + id: stickyRow + anchors.centerIn: parent + spacing: 6 + + Rectangle { + width: 30; height: 30; radius: 7 + color: ocrArea.containsMouse ? "#F0FDF4" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_ocr.svg"; width: 18; height: 18 } + MouseArea { + id: ocrArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: stickyWin.showToolbarTip(qsTr("OCR"), parent) + onExited: stickyWin.hideToolbarTip(qsTr("OCR")) + onPressed: { + var img = O_StickyViewModel.getImageByUrl(stickyWin.imageUrl) + O_OcrVM.recognize(img) + } + } + } + + Rectangle { width: 1; height: 18; color: "#E2E8F0"; anchors.verticalCenter: parent.verticalCenter } + + Rectangle { + width: 30; height: 30; radius: 7 + color: (stickyWin.activeTool === "pencil") ? "#EFF6FF" : (pencilArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (stickyWin.activeTool === "pencil") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_pencil.svg"; width: 18; height: 18 } + MouseArea { id: pencilArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: stickyWin.showToolbarTip(qsTr("Pencil"), parent); onExited: stickyWin.hideToolbarTip(qsTr("Pencil")); onPressed: stickyWin.setActiveTool("pencil") } + } + + Rectangle { + width: 30; height: 30; radius: 7 + color: (stickyWin.activeTool === "rect") ? "#EFF6FF" : (rectArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (stickyWin.activeTool === "rect") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_square.svg"; width: 18; height: 18 } + MouseArea { id: rectArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: stickyWin.showToolbarTip(qsTr("Rectangle"), parent); onExited: stickyWin.hideToolbarTip(qsTr("Rectangle")); onPressed: stickyWin.setActiveTool("rect") } + } + + Rectangle { + width: 30; height: 30; radius: 7 + color: (stickyWin.activeTool === "circle") ? "#EFF6FF" : (circleArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (stickyWin.activeTool === "circle") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_circle.svg"; width: 18; height: 18 } + MouseArea { id: circleArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: stickyWin.showToolbarTip(qsTr("Circle"), parent); onExited: stickyWin.hideToolbarTip(qsTr("Circle")); onPressed: stickyWin.setActiveTool("circle") } + } + + Rectangle { + width: 30; height: 30; radius: 7 + color: (stickyWin.activeTool === "arrow") ? "#EFF6FF" : (arrowArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (stickyWin.activeTool === "arrow") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_arrow.svg"; width: 18; height: 18 } + MouseArea { id: arrowArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: stickyWin.showToolbarTip(qsTr("Arrow"), parent); onExited: stickyWin.hideToolbarTip(qsTr("Arrow")); onPressed: stickyWin.setActiveTool("arrow") } + } + + Rectangle { + width: 30; height: 30; radius: 7 + color: (stickyWin.activeTool === "mosaic") ? "#EFF6FF" : (mosaicArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (stickyWin.activeTool === "mosaic") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_mosaic.svg"; width: 18; height: 18 } + MouseArea { + id: mosaicArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: stickyWin.showToolbarTip(qsTr("Mosaic"), parent) + onExited: stickyWin.hideToolbarTip(qsTr("Mosaic")) + onPressed: { + var img = O_StickyViewModel.getImageByUrl(stickyWin.imageUrl) + if (img && img.width > 0 && img.height > 0) + stickyPaintBoard.setBackgroundImg(img) + stickyWin.activeTool = "mosaic" + if (stickyWin.activeTool === "mosaic" && !stickyPaintBoard.hasBackgroundImg()) { + Qt.callLater(function() { + var retryImg = O_StickyViewModel.getImageByUrl(stickyWin.imageUrl) + if (retryImg && retryImg.width > 0 && retryImg.height > 0) + stickyPaintBoard.setBackgroundImg(retryImg) + if (!stickyPaintBoard.hasBackgroundImg()) + stickyWin.syncPaintboardBackgroundFromDisplay() + }) + } + } + } + } + + Rectangle { width: 1; height: 18; color: "#E2E8F0"; anchors.verticalCenter: parent.verticalCenter } + + Rectangle { width: 30; height: 30; radius: 7; color: zoomOutArea.containsMouse ? "#F1F5F9" : "transparent"; Text { anchors.centerIn: parent; text: "-"; color: "#334155"; font.pixelSize: 16 } MouseArea { id: zoomOutArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: stickyWin.showToolbarTip(qsTr("Zoom out"), parent); onExited: stickyWin.hideToolbarTip(qsTr("Zoom out")); onPressed: setZoomValue(stickyWin.zoomFactor - 0.1) } } + Rectangle { width: 40; height: 30; radius: 7; color: "transparent"; Text { anchors.centerIn: parent; text: Math.round(stickyWin.zoomFactor * 100) + "%"; color: "#64748B"; font.pixelSize: 11 } } + Rectangle { width: 30; height: 30; radius: 7; color: zoomInArea.containsMouse ? "#F1F5F9" : "transparent"; Text { anchors.centerIn: parent; text: "+"; color: "#334155"; font.pixelSize: 16 } MouseArea { id: zoomInArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: stickyWin.showToolbarTip(qsTr("Zoom in"), parent); onExited: stickyWin.hideToolbarTip(qsTr("Zoom in")); onPressed: setZoomValue(stickyWin.zoomFactor + 0.1) } } + Rectangle { width: 30; height: 30; radius: 7; color: rotateArea.containsMouse ? "#F1F5F9" : "transparent"; Text { anchors.centerIn: parent; text: "↻"; color: "#334155"; font.pixelSize: 14 } MouseArea { id: rotateArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: stickyWin.showToolbarTip(qsTr("Rotate 90°"), parent); onExited: stickyWin.hideToolbarTip(qsTr("Rotate 90°")); onPressed: rotateCurrent(90) } } + Rectangle { width: 30; height: 30; radius: 7; color: mirrorArea.containsMouse ? "#F1F5F9" : "transparent"; Text { anchors.centerIn: parent; text: "⇋"; color: "#334155"; font.pixelSize: 14 } MouseArea { id: mirrorArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: stickyWin.showToolbarTip(qsTr("Mirror"), parent); onExited: stickyWin.hideToolbarTip(qsTr("Mirror")); onPressed: mirrorCurrent() } } + Rectangle { width: 30; height: 30; radius: 7; color: resetSizeArea.containsMouse ? "#F1F5F9" : "transparent"; Text { anchors.centerIn: parent; text: "1:1"; color: "#334155"; font.pixelSize: 10 } MouseArea { id: resetSizeArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: stickyWin.showToolbarTip(qsTr("Reset size"), parent); onExited: stickyWin.hideToolbarTip(qsTr("Reset size")); onPressed: resetDisplaySize() } } + + Rectangle { + width: 30; height: 30; radius: 7 + color: overwriteArea.containsMouse ? "#E0F2FE" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_check.svg"; width: 18; height: 18 } + MouseArea { id: overwriteArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: stickyWin.showToolbarTip(qsTr("Done and copy"), parent); onExited: stickyWin.hideToolbarTip(qsTr("Done and copy")); onPressed: completeEditsAndCopy() } + } + + Rectangle { + width: 30; height: 30; radius: 7 + color: undoArea.containsMouse ? "#EFF6FF" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_undo.svg"; width: 18; height: 18 } + MouseArea { id: undoArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: stickyWin.showToolbarTip(qsTr("Undo"), parent); onExited: stickyWin.hideToolbarTip(qsTr("Undo")); onPressed: stickyPaintBoard.undo() } + } + + Rectangle { + width: 30; height: 30; radius: 7 + color: clearToolArea.containsMouse ? "#FEE2E2" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_x.svg"; width: 18; height: 18 } + MouseArea { + id: clearToolArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: stickyWin.showToolbarTip(qsTr("Hide toolbar"), parent) + onExited: stickyWin.hideToolbarTip(qsTr("Hide toolbar")) + onPressed: { + stickyWin.activeTool = "" + stickyWin.toolbarVisible = false + } + } + } + } + } + + Rectangle { + id: stickyToolbarTip + parent: stickyWin.contentItem + visible: stickyWin.toolbarVisible && stickyWin.toolbarHoverTipText.length > 0 + z: 40 + width: Math.min(stickyWin.width - 8, Math.max(64, stickyToolbarTipText.implicitWidth + 16)) + height: 24 + radius: 6 + color: "#0F172A" + opacity: 0.92 + x: Math.max(4, Math.min(stickyWin.width - width - 4, stickyToolbar.x + stickyWin.toolbarHoverTipCenterX - width / 2)) + y: Math.min(stickyWin.height - height - 4, stickyToolbar.y + stickyToolbar.height + stickyWin.toolbarTipGap) + + Text { + id: stickyToolbarTipText + anchors.centerIn: parent + text: stickyWin.toolbarHoverTipText + color: "#FFFFFF" + font.pixelSize: 11 + } + } + + readonly property int chatPanelGap: 6 + + TZChat { + id: chatPanel + imageUrl: stickyWin.imageUrl + onSignalSendPrompt: function(text) { + O_AIViewModel.sendPrompt(text, stickyWin.imageUrl) + } + } + + Connections { + target: O_AIViewModel + function onSignalRequestComplete(oldImageUrl, newImageUrl) { + if (oldImageUrl !== stickyWin.imageUrl) + return + var old = oldImageUrl + stickyWin.imageUrl = newImageUrl + Qt.callLater(function() { O_StickyViewModel.releaseImage(old) }) + } + function onSignalRequestFailed(errorMsg) { + console.warn("[TZStickyWindow] AI request failed: " + errorMsg) + } + } + + function openChatPanel() { + var imgLeft = stickyWin.x + stickyWin.shadowRadius + var imgTop = stickyWin.y + stickyWin.shadowRadius + var imgBottom = imgTop + stickyWin.displayH + + var screenH = Screen.desktopAvailableHeight + var screenW = Screen.desktopAvailableWidth + + var cx = imgLeft + if (cx + chatPanel.win.width > screenW) + cx = imgLeft + stickyWin.displayW - chatPanel.win.width + + var cy + if (imgBottom + chatPanelGap + chatPanel.win.height <= screenH) + cy = imgBottom + chatPanelGap + else + cy = imgTop - chatPanelGap - chatPanel.win.height + + chatPanel.open(Math.max(0, cx), Math.max(0, cy)) + } + + Platform.Menu { + id: contextMenu + onAboutToShow: stickyWin.contextMenuOpen = true + onAboutToHide: stickyWin.contextMenuOpen = false + + Platform.MenuItem { text: qsTr("当前透明度 %1%").arg(Math.round(stickyWin.imgOpacity * 100)); enabled: false } + Platform.MenuSeparator {} + + Platform.MenuItem { visible: menuPrefs.pinSaveMerged; text: qsTr("覆盖贴图(合并标注)"); onTriggered: overwriteWithEdits() } + Platform.MenuItem { visible: menuPrefs.pinCopy; text: qsTr("复制贴图"); onTriggered: O_StickyViewModel.copyImageToClipboard(imageUrl) } + Platform.MenuItem { visible: menuPrefs.pinAi; text: qsTr("AI 编辑"); onTriggered: openChatPanel() } + Platform.MenuItem { + visible: menuPrefs.pinToolbar + text: stickyWin.toolbarVisible ? qsTr("隐藏工具栏") : qsTr("显示工具栏") + onTriggered: { + stickyWin.toolbarVisible = !stickyWin.toolbarVisible + if (!stickyWin.toolbarVisible) + stickyWin.activeTool = "" + } + } + Platform.MenuItem { visible: menuPrefs.pinClose; text: qsTr("关闭窗口"); onTriggered: stickyWin.close() } + + Platform.MenuSeparator {} + + Platform.MenuItem { text: qsTr("透明度 +5%"); onTriggered: setOpacityValue(stickyWin.imgOpacity + 0.05) } + Platform.MenuItem { text: qsTr("透明度 -5%"); onTriggered: setOpacityValue(stickyWin.imgOpacity - 0.05) } + + Platform.MenuSeparator {} + + Platform.MenuItem { text: qsTr("缩小"); onTriggered: setZoomValue(stickyWin.zoomFactor - 0.1) } + Platform.MenuItem { text: qsTr("放大"); onTriggered: setZoomValue(stickyWin.zoomFactor + 0.1) } + Platform.MenuItem { text: qsTr("旋转 90°"); onTriggered: rotateCurrent(90) } + Platform.MenuItem { text: qsTr("镜像"); onTriggered: mirrorCurrent() } + Platform.MenuItem { text: qsTr("恢复原始大小"); onTriggered: resetDisplaySize() } + + Platform.MenuSeparator {} + + Platform.MenuItem { + text: qsTr("重置位置") + onTriggered: { + stickyWin.x = (imgRect.width >= 0 ? imgRect.x : imgRect.x + imgRect.width) - shadowRadius + stickyWin.y = (imgRect.height >= 0 ? imgRect.y : imgRect.y + imgRect.height) - shadowRadius + } + } + + Platform.MenuSeparator {} + + Platform.MenuItem { + text: stickyWin.toolbarVisible ? qsTr("隐藏工具栏") : qsTr("显示工具栏") + onTriggered: { + stickyWin.toolbarVisible = !stickyWin.toolbarVisible + if (!stickyWin.toolbarVisible) + stickyWin.activeTool = "" + } + } + + Platform.MenuItem { text: qsTr("AI 编辑"); onTriggered: openChatPanel() } + + Platform.MenuSeparator {} + + Platform.MenuItem { text: qsTr("保存原图"); onTriggered: saveDialog.open() } + Platform.MenuItem { text: qsTr("另存为(合并标注)"); onTriggered: mergedSaveDialog.open() } + Platform.MenuItem { text: qsTr("覆盖贴图(合并标注)"); onTriggered: overwriteWithEdits() } + Platform.MenuItem { text: qsTr("复制贴图"); onTriggered: O_StickyViewModel.copyImageToClipboard(imageUrl) } + + Platform.MenuSeparator {} + + Platform.Menu { + title: qsTr("常用项置顶") + Platform.MenuItem { text: qsTr("覆盖贴图"); checkable: true; checked: menuPrefs.pinSaveMerged; onTriggered: menuPrefs.pinSaveMerged = !menuPrefs.pinSaveMerged } + Platform.MenuItem { text: qsTr("复制贴图"); checkable: true; checked: menuPrefs.pinCopy; onTriggered: menuPrefs.pinCopy = !menuPrefs.pinCopy } + Platform.MenuItem { text: qsTr("AI 编辑"); checkable: true; checked: menuPrefs.pinAi; onTriggered: menuPrefs.pinAi = !menuPrefs.pinAi } + Platform.MenuItem { text: qsTr("显示/隐藏工具栏"); checkable: true; checked: menuPrefs.pinToolbar; onTriggered: menuPrefs.pinToolbar = !menuPrefs.pinToolbar } + Platform.MenuItem { text: qsTr("关闭窗口"); checkable: true; checked: menuPrefs.pinClose; onTriggered: menuPrefs.pinClose = !menuPrefs.pinClose } + } + + Platform.MenuSeparator {} + + Platform.MenuItem { text: qsTr("关闭窗口"); onTriggered: stickyWin.close() } + } + + Platform.FileDialog { + id: saveDialog + title: qsTr("选择保存位置") + folder: O_ImageSaver.savePath + fileMode: Platform.FileDialog.SaveFile + nameFilters: ["PNG (*.png)", "JPG (*.jpg)", "BMP (*.bmp)"] + onAccepted: O_StickyViewModel.saveImage(imageUrl, saveDialog.file) + } + + Platform.FileDialog { + id: mergedSaveDialog + title: qsTr("另存为(合并标注)") + folder: O_ImageSaver.savePath + fileMode: Platform.FileDialog.SaveFile + nameFilters: ["PNG (*.png)", "JPG (*.jpg)", "BMP (*.bmp)"] + onAccepted: saveMergedTo(mergedSaveDialog.file) + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + enabled: stickyWin.activeTool === "" && !stickyWin.contextMenuOpen + property point dragPos: Qt.point(0, 0) + property bool leftDragActive: false + z: 0 + + function inImageArea(mx, my) { + return mx >= stickyWin.shadowRadius + && mx <= stickyWin.shadowRadius + stickyWin.displayW + && my >= stickyWin.shadowRadius + stickyWin.shadowOffset / 2 + && my <= stickyWin.shadowRadius + stickyWin.shadowOffset / 2 + stickyWin.displayH + } + + onPressed: function(mouse) { + if (mouse.button === Qt.LeftButton) { + dragPos = Qt.point(mouseX, mouseY) + leftDragActive = true + } else { + leftDragActive = false + } + } + + onPositionChanged: function(mouse) { + if (pressed && leftDragActive && (mouse.buttons & Qt.LeftButton) && stickyWin.activeTool === "" && !stickyWin.contextMenuOpen) { + stickyWin.x += mouseX - dragPos.x + stickyWin.y += mouseY - dragPos.y + } + } + + onReleased: function(mouse) { + if (!(mouse.buttons & Qt.LeftButton)) + leftDragActive = false + } + + onDoubleClicked: function(mouse) { + if (mouse.button === Qt.LeftButton && inImageArea(mouseX, mouseY)) + resetDisplaySize() + } + + onClicked: function(mouse) { + if (mouse.button === Qt.RightButton) { + leftDragActive = false + contextMenu.open() + } + } + + onWheel: function(wheel) { + if (wheel.modifiers & Qt.ControlModifier) { + if (wheel.angleDelta.y > 0) + setZoomValue(stickyWin.zoomFactor + 0.1) + else if (wheel.angleDelta.y < 0) + setZoomValue(stickyWin.zoomFactor - 0.1) + } else { + if (wheel.angleDelta.y > 0) + setOpacityValue(stickyWin.imgOpacity + 0.05) + else if (wheel.angleDelta.y < 0) + setOpacityValue(stickyWin.imgOpacity - 0.05) + } + wheel.accepted = true + } + } +} + diff --git a/qml/TZTool.qml b/qml/TZTool.qml new file mode 100644 index 0000000..fbcd9ad --- /dev/null +++ b/qml/TZTool.qml @@ -0,0 +1,222 @@ +import QtQuick 2.12 +import QtQuick.Layouts 1.12 +import QtQuick.Controls 2.5 + +Rectangle { + id: tool + radius: 10 + color: "#FFFFFF" + visible: false + border.color: "#E2E8F0" + border.width: 1 + layer.enabled: true + + signal signalScreenshot + signal signalCancel + signal signalSave + signal signalSticky + signal signalUndo + signal signalGifRecord + signal signalOcr + + property string activeTool: "" + property bool markToolInfoVisible: false + property string hoverTipText: "" + property real hoverTipCenterX: width / 2 + + readonly property int shapeType: { + switch (activeTool) { + case "pencil": return 0 + case "rect": return 1 + case "circle": return 2 + case "arrow": return 3 + case "mosaic": return 4 + case "text": return 5 + case "number": return 6 + case "highlight": return 7 + default: return 1 + } + } + + function setActiveTool(name) { + if (activeTool === name) { + activeTool = "" + markToolInfoVisible = false + } else { + activeTool = name + markToolInfoVisible = true + } + } + + function showTip(text, targetItem) { + hoverTipText = text + if (targetItem) { + var p = tool.mapFromItem(targetItem, targetItem.width / 2, targetItem.height) + hoverTipCenterX = p.x + } else { + hoverTipCenterX = width / 2 + } + } + + function hideTip(text) { + if (text === undefined || hoverTipText === text) + hoverTipText = "" + } + + RowLayout { + anchors.fill: parent + anchors.margins: 6 + layoutDirection: Qt.RightToLeft + spacing: 2 + + Rectangle { + width: 32; height: 32; radius: 7 + color: checkArea.containsMouse ? "#DCFCE7" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_check.svg"; width: 18; height: 18 } + MouseArea { id: checkArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("完成"), parent); onExited: tool.hideTip(qsTr("完成")); onPressed: signalScreenshot() } + } + + Rectangle { + width: 32; height: 32; radius: 7 + color: closeArea.containsMouse ? "#FEE2E2" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_x.svg"; width: 18; height: 18 } + MouseArea { id: closeArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("取消"), parent); onExited: tool.hideTip(qsTr("取消")); onPressed: signalCancel() } + } + + Rectangle { width: 1; height: 20; color: "#E2E8F0"; Layout.alignment: Qt.AlignVCenter } + + Rectangle { + width: 32; height: 32; radius: 7 + color: ocrArea.containsMouse ? "#F0FDF4" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_ocr.svg"; width: 18; height: 18 } + MouseArea { id: ocrArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("OCR"), parent); onExited: tool.hideTip(qsTr("OCR")); onPressed: signalOcr() } + } + + Rectangle { + id: gifRecordBtn + width: 32; height: 32; radius: 7 + color: gifArea.containsMouse ? "#FFF7ED" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_gif_record.svg"; width: 18; height: 18 } + MouseArea { id: gifArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("录制GIF"), parent); onExited: tool.hideTip(qsTr("录制GIF")); onPressed: signalGifRecord() } + } + + Rectangle { width: 1; height: 20; color: "#E2E8F0"; Layout.alignment: Qt.AlignVCenter } + + Rectangle { + width: 32; height: 32; radius: 7 + color: saveArea.containsMouse ? "#EFF6FF" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_save.svg"; width: 18; height: 18 } + MouseArea { id: saveArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("保存"), parent); onExited: tool.hideTip(qsTr("保存")); onPressed: signalSave() } + } + + Rectangle { + width: 32; height: 32; radius: 7 + color: fixArea.containsMouse ? "#EFF6FF" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_pin.svg"; width: 18; height: 18 } + MouseArea { id: fixArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("贴图"), parent); onExited: tool.hideTip(qsTr("贴图")); onPressed: signalSticky() } + } + + Rectangle { width: 1; height: 20; color: "#E2E8F0"; Layout.alignment: Qt.AlignVCenter } + + Rectangle { + width: 32; height: 32; radius: 7 + color: (tool.activeTool === "rect") ? "#EFF6FF" : (rectArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (tool.activeTool === "rect") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_square.svg"; width: 18; height: 18 } + MouseArea { id: rectArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("矩形"), parent); onExited: tool.hideTip(qsTr("矩形")); onPressed: tool.setActiveTool("rect") } + } + + Rectangle { + width: 32; height: 32; radius: 7 + color: (tool.activeTool === "circle") ? "#EFF6FF" : (circleArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (tool.activeTool === "circle") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_circle.svg"; width: 18; height: 18 } + MouseArea { id: circleArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("圆形"), parent); onExited: tool.hideTip(qsTr("圆形")); onPressed: tool.setActiveTool("circle") } + } + + Rectangle { + width: 32; height: 32; radius: 7 + color: (tool.activeTool === "arrow") ? "#EFF6FF" : (arrowArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (tool.activeTool === "arrow") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_arrow.svg"; width: 18; height: 18 } + MouseArea { id: arrowArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("箭头"), parent); onExited: tool.hideTip(qsTr("箭头")); onPressed: tool.setActiveTool("arrow") } + } + + Rectangle { + width: 32; height: 32; radius: 7 + color: (tool.activeTool === "pencil") ? "#EFF6FF" : (pencilArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (tool.activeTool === "pencil") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_pencil.svg"; width: 18; height: 18 } + MouseArea { id: pencilArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("画笔"), parent); onExited: tool.hideTip(qsTr("画笔")); onPressed: tool.setActiveTool("pencil") } + } + + Rectangle { + width: 32; height: 32; radius: 7 + color: (tool.activeTool === "highlight") ? "#EFF6FF" : (highlightArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (tool.activeTool === "highlight") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_highlighter.svg"; width: 18; height: 18 } + MouseArea { id: highlightArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("高亮"), parent); onExited: tool.hideTip(qsTr("高亮")); onPressed: tool.setActiveTool("highlight") } + } + + Rectangle { + width: 32; height: 32; radius: 7 + color: (tool.activeTool === "mosaic") ? "#EFF6FF" : (mosaicArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (tool.activeTool === "mosaic") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_mosaic.svg"; width: 18; height: 18 } + MouseArea { id: mosaicArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("马赛克"), parent); onExited: tool.hideTip(qsTr("马赛克")); onPressed: tool.setActiveTool("mosaic") } + } + + Rectangle { + width: 32; height: 32; radius: 7 + color: (tool.activeTool === "text") ? "#EFF6FF" : (textArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (tool.activeTool === "text") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_text.svg"; width: 18; height: 18 } + MouseArea { id: textArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("文字"), parent); onExited: tool.hideTip(qsTr("文字")); onPressed: tool.setActiveTool("text") } + } + + Rectangle { + width: 32; height: 32; radius: 7 + color: (tool.activeTool === "number") ? "#EFF6FF" : (numberArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (tool.activeTool === "number") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_list_ordered.svg"; width: 18; height: 18 } + MouseArea { id: numberArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("序号"), parent); onExited: tool.hideTip(qsTr("序号")); onPressed: tool.setActiveTool("number") } + } + + Rectangle { + width: 32; height: 32; radius: 7 + color: undoArea.containsMouse ? "#EFF6FF" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_undo.svg"; width: 18; height: 18 } + MouseArea { id: undoArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("撤销"), parent); onExited: tool.hideTip(qsTr("撤销")); onPressed: signalUndo() } + } + } + + Rectangle { + id: tipBubble + visible: tool.visible && tool.hoverTipText.length > 0 + z: 100 + parent: tool.parent + width: Math.max(44, tipText.implicitWidth + 14) + height: 24 + radius: 6 + color: "#0F172A" + opacity: 0.92 + x: Math.max(tool.x, Math.min(tool.x + tool.width - width, tool.x + tool.hoverTipCenterX - width / 2)) + y: tool.y + tool.height + 6 + + Text { + id: tipText + anchors.centerIn: parent + text: tool.hoverTipText + color: "#FFFFFF" + font.pixelSize: 11 + } + } +} diff --git a/qml/TZWindow.qml b/qml/TZWindow.qml new file mode 100644 index 0000000..ebcc2d0 --- /dev/null +++ b/qml/TZWindow.qml @@ -0,0 +1,1030 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Shapes +import QtQuick.Layouts +import QtQuick.Dialogs +import CustomComponents 1.0 + +Window { + id: mainWin + color: "transparent"; + + // Virtual desktop offset injected by main.qml + property int screenVirtualX: 0 + property int screenVirtualY: 0 + + property rect initialScreenshotArea: Qt.rect(0, 0, 0, 0) + property rect screenshotArea: Qt.rect(0, 0, 0, 0) + property var offset: Qt.point(0, 0) + property string screenShotUrl: "" + + property rect highlightRect: Qt.rect(0, 0, 0, 0) + property point pendingTextPoint: Qt.point(0, 0) + property string pendingTextValue: "" + property bool captureUiReady: false + + property var stickyWinObj: null + property var stickyWinList: [] + property var stickyComponent: null + property bool gifRecordMode: false + readonly property bool gifAreaLocked: O_GifRecordVM.isRecording || O_GifRecordVM.isEncoding + property var ocrComponent: null + property var ocrWinList: [] + property bool spaceHeld: false + property bool hotkeyGestureEnabled: false + property bool hotkeyGestureArmed: false + property bool hotkeyLongPressed: false + property string hotkeyMainToken: "" + property bool textClickPending: false + property bool textPressMoved: false + property point textClickAbsPoint: Qt.point(0, 0) + property point textClickLocalPoint: Qt.point(0, 0) + + property alias tool: tool + property alias paintBoard: paintBoard + property alias magnifier: magnifier + property alias gifBarObj: gifBar + + function hasSelectionArea() { + return Math.abs(screenshotArea.width) > 2 && Math.abs(screenshotArea.height) > 2 + } + + function parseMainKeyToken(shortcutSeq) { + if (!shortcutSeq || shortcutSeq.length === 0) + return "" + var parts = shortcutSeq.split("+") + if (parts.length === 0) + return "" + return parts[parts.length - 1].trim().toUpperCase() + } + + function keyMatchesToken(key, token) { + if (!token || token.length === 0) + return false + if (token.length === 1) { + if (token >= "A" && token <= "Z") + return key === (Qt.Key_A + (token.charCodeAt(0) - "A".charCodeAt(0))) + if (token >= "0" && token <= "9") + return key === (Qt.Key_0 + (token.charCodeAt(0) - "0".charCodeAt(0))) + } + if (token[0] === "F") { + var fn = Number(token.slice(1)) + if (fn >= 1 && fn <= 24) + return key === (Qt.Key_F1 + fn - 1) + } + if (token === "SPACE") + return key === Qt.Key_Space + if (token === "ESC" || token === "ESCAPE") + return key === Qt.Key_Escape + if (token === "ENTER" || token === "RETURN") + return key === Qt.Key_Return || key === Qt.Key_Enter + return false + } + + function beginHotkeyGesture(shortcutSeq) { + hotkeyGestureEnabled = true + hotkeyGestureArmed = false + hotkeyLongPressed = false + hotkeyMainToken = parseMainKeyToken(shortcutSeq) + hotkeyLongPressTimer.stop() + } + + function clearHotkeyGesture() { + hotkeyGestureEnabled = false + hotkeyGestureArmed = false + hotkeyLongPressed = false + hotkeyMainToken = "" + hotkeyLongPressTimer.stop() + } + + function maybeArmHotkeyGesture() { + if (!hotkeyGestureEnabled || !hasSelectionArea()) + return + hotkeyGestureArmed = true + hotkeyLongPressed = false + hotkeyLongPressTimer.restart() + } + + function selectionRectFromDrag(startX, startY, endX, endY, withShift, withAlt) { + var x = startX + var y = startY + var w = endX - startX + var h = endY - startY + + if (withAlt) { + x = startX - (endX - startX) + y = startY - (endY - startY) + w = (endX - startX) * 2 + h = (endY - startY) * 2 + } + + if (withShift) { + var absW = Math.abs(w) + var absH = Math.abs(h) + var side = Math.min(absW, absH) + w = w < 0 ? -side : side + h = h < 0 ? -side : side + } + + return Qt.rect(x, y, w, h) + } + + function confirmSelectionToClipboard() { + root.visibility = Window.Hidden + Qt.callLater(function() { + var success = O_ScreenCapture.captureRectToClipboard(paintBoard, screenshotArea) + resetArea() + if (success) + trayHelper.showMessage(qsTr("Capture done"), qsTr("Copied to clipboard")) + else + trayHelper.showMessage(qsTr("Capture failed"), qsTr("Failed to copy image")) + }) + } + + function handleEscAction() { + if (textEditor.visible) { + hideTextEditor() + return true + } + + if (ocrWinList.length > 0) { + var topWin = ocrWinList[ocrWinList.length - 1] + if (topWin) + topWin.close() + return true + } + + if (gifRecordMode) { + O_GifRecordVM.cancelRecording() + gifBar.visible = false + gifRecordMode = false + if (hasSelectionArea()) + tool.visible = true + return true + } + + if (tool.activeTool !== "") { + tool.activeTool = "" + return true + } + + if (tool.markToolInfoVisible) { + tool.markToolInfoVisible = false + return true + } + + if (tool.visible) { + tool.visible = false + return true + } + + if (hasSelectionArea()) { + resetArea() + return true + } + + return false + } + + function handleMousePressed(ax, ay) { + if (screenshotArea.width !== 0 || screenshotArea.height !== 0) { + if (isPointInRect(screenshotArea, ax, ay)) { + offset.x = ax + offset.y = ay + } else { + paintBoard.reset() + tool.activeTool = "" + tool.markToolInfoVisible = false + tool.visible = false + resizeHandleGroup.visible = false + hideTextEditor() + var winRect = O_ScreenCapture.windowAtPoint(ax, ay) + highlightRect = winRect.width > 0 + ? Qt.rect(winRect.x, winRect.y, winRect.width, winRect.height) + : Qt.rect(0, 0, 0, 0) + initialScreenshotArea = Qt.rect(ax, ay, 0, 0) + screenshotArea = Qt.rect(ax, ay, 0, 0) + } + } else { + paintBoard.reset() + tool.activeTool = "" + initialScreenshotArea = Qt.rect(ax, ay, 0, 0) + screenshotArea = Qt.rect(ax, ay, 0, 0) + } + } + + function handleMouseMoved(ax, ay, isPressed, modifiers) { + if(isPressed) + resizeHandleGroup.visible = false; + var c = O_ScreenCapture.getPixelColor(ax, ay) + if (c !== undefined && c !== null) + magnifier.magnifierPixelColor = c + + if (!isPressed && screenshotArea.width === 0 && screenshotArea.height === 0) { + var winRect = O_ScreenCapture.windowAtPoint(ax, ay) + highlightRect = winRect.width > 0 + ? Qt.rect(winRect.x, winRect.y, winRect.width, winRect.height) + : Qt.rect(0, 0, 0, 0) + return + } + if (screenshotArea.width !== 0 && screenshotArea.height !== 0) { + if (isPointInRect(screenshotArea, ax, ay) && isPressed) { + let dx = ax - offset.x + let dy = ay - offset.y + screenshotArea = Qt.rect( + initialScreenshotArea.x + dx, + initialScreenshotArea.y + dy, + screenshotArea.width, screenshotArea.height) + return + } + } + if (isPressed && !isPointInRect(screenshotArea, ax, ay)) { + var useShift = (modifiers & Qt.ShiftModifier) !== 0 + var useAlt = (modifiers & Qt.AltModifier) !== 0 + screenshotArea = selectionRectFromDrag( + initialScreenshotArea.x, initialScreenshotArea.y, ax, ay, useShift, useAlt) + if (tool.visible) tool.visible = false + } + //resizeHandleGroup.area = localScreenshotArea + } + + function handleMouseReleased() { + if (Math.abs(screenshotArea.width) <= 5 && Math.abs(screenshotArea.height) <= 5) { + if (highlightRect.width > 0 && highlightRect.height > 0) { + screenshotArea = highlightRect + initialScreenshotArea = highlightRect + resizeHandleGroup.area = localScreenshotArea + resizeHandleGroup.initialArea = Qt.rect( + initialScreenshotArea.x - screenVirtualX, + initialScreenshotArea.y - screenVirtualY, + initialScreenshotArea.width, initialScreenshotArea.height) + highlightRect = Qt.rect(0, 0, 0, 0) + Qt.callLater(function() { tool.visible = true }) + } + resizeHandleGroup.visible = (highlightRect.width > 0 && highlightRect.height > 0) + return + } + initialScreenshotArea = screenshotArea + resizeHandleGroup.initialArea = Qt.rect( + initialScreenshotArea.x - screenVirtualX, + initialScreenshotArea.y - screenVirtualY, + initialScreenshotArea.width, initialScreenshotArea.height) + if (Math.abs(screenshotArea.width) > 2 && Math.abs(screenshotArea.height) > 2) { + Qt.callLater(function() { tool.visible = true }) + } + resizeHandleGroup.area = localScreenshotArea + resizeHandleGroup.visible = true; + + } + // 鏈湴鍧愭爣 -> 铏氭嫙妗岄潰缁濆鍧愭爣 + function toAbsX(localX) { return localX + screenVirtualX } + function toAbsY(localY) { return localY + screenVirtualY } + + // screenshotArea 鍦ㄦ湰绐楀彛鏈湴鍧愭爣涓殑绛変环鐭╁舰锛堢敤浜庣粯鍒讹級 + readonly property rect localScreenshotArea: Qt.rect( + screenshotArea.x - screenVirtualX, + screenshotArea.y - screenVirtualY, + screenshotArea.width, + screenshotArea.height + ) + readonly property rect localHighlightRect: Qt.rect( + highlightRect.x - screenVirtualX, + highlightRect.y - screenVirtualY, + highlightRect.width, + highlightRect.height + ) + + function getNormalizedRectBounds(rect) { + if (rect.width === 0 || rect.height === 0) { + return { left: 0, right: 0, top: 0, bottom: 0, isValid: false }; + } + let left = Math.min(rect.x, rect.x + rect.width); + let right = Math.max(rect.x, rect.x + rect.width); + let top = Math.min(rect.y, rect.y + rect.height); + let bottom = Math.max(rect.y, rect.y + rect.height); + return { left: left, right: right, top: top, bottom: bottom, + width: right - left, height: bottom - top, isValid: true }; + } + + function isPointInRect(rect, x, y) { + let bounds = getNormalizedRectBounds(rect); + if (!bounds.isValid) return false; + return (x >= bounds.left && x <= bounds.right) + && (y >= bounds.top && y <= bounds.bottom); + } + + function createStickyWindow(imageUrl, imgRect) { + if (!imageUrl || imageUrl.length === 0) { + console.error("Failed to create sticky window: invalid image URL") + return + } + if (stickyComponent === null) + stickyComponent = Qt.createComponent("qrc:/qml/TZStickyWindow.qml"); + + var newWinObj = null; + if (stickyComponent.status === Component.Ready) { + newWinObj = stickyComponent.createObject(null, { + "imageUrl": imageUrl, "imgRect": imgRect }); + } else if (stickyComponent.status === Component.Error) { + console.error("Failed to load TZStickyWindow.qml:", stickyComponent.errorString()); + } + if (newWinObj) { + newWinObj.visible = true; + newWinObj.raise() + newWinObj.requestActivate() + var newList = stickyWinList.slice(); + newList.push(newWinObj); + stickyWinList = newList; + var winRef = newWinObj; + function onWinClosing(closeEvent) { + var idx = stickyWinList.indexOf(winRef); + if (idx !== -1) { + var updatedList = stickyWinList.slice(); + updatedList.splice(idx, 1); + stickyWinList = updatedList; + } + winRef.closing.disconnect(onWinClosing); + winRef = null; + } + newWinObj.closing.connect(onWinClosing); + } else { + console.error("Failed to create sticky window object"); + } + } + + function createOcrResultWindow() { + if (ocrWinList.length > 0) { + var existing = ocrWinList[ocrWinList.length - 1] + if (existing) { + existing.show() + existing.raise() + existing.requestActivate() + return + } + } + + if (ocrComponent === null) + ocrComponent = Qt.createComponent("qrc:/qml/TZOcrResult.qml") + if (ocrComponent.status === Component.Ready) { + var win = ocrComponent.createObject(null) + if (win) { + win.show() + win.raise() + win.requestActivate() + var list = ocrWinList.slice() + list.push(win) + ocrWinList = list + var winRef = win + function onOcrClosing(closeEvent) { + var idx = ocrWinList.indexOf(winRef) + if (idx !== -1) { + var updatedList = ocrWinList.slice() + updatedList.splice(idx, 1) + ocrWinList = updatedList + } + winRef.closing.disconnect(onOcrClosing) + winRef = null + } + win.closing.connect(onOcrClosing) + } + } else if (ocrComponent.status === Component.Error) { + console.error("Failed to load TZOcrResult.qml:", ocrComponent.errorString()) + } + } + + function openTextEditor(localX, localY) { + pendingTextPoint = Qt.point(localX, localY) + pendingTextValue = markToolInfo.annotationText + textInput.text = pendingTextValue + textEditor.visible = true + textEditor.x = Math.max(0, Math.min(localX, mainWin.width - textEditor.width)) + textEditor.y = Math.max(0, Math.min(localY + 8, mainWin.height - textEditor.height)) + textInput.forceActiveFocus() + textInput.selectAll() + } + + function hideTextEditor() { + textEditor.visible = false + } + + function submitTextEditor() { + var text = textInput.text.trim() + if (text.length === 0) { + hideTextEditor() + return + } + markToolInfo.annotationText = text + paintBoard.addTextAnnotation(Math.round(pendingTextPoint.x), Math.round(pendingTextPoint.y), text) + hideTextEditor() + } + + function resetArea() { + screenshotArea = Qt.rect(0, 0, 0, 0) + initialScreenshotArea = Qt.rect(0, 0, 0, 0) + resizeHandleGroup.area = Qt.rect(0, 0, 0, 0) + resizeHandleGroup.initialArea = Qt.rect(0, 0, 0, 0) + gifResizeHandleGroup.area = Qt.rect(0, 0, 0, 0) + gifResizeHandleGroup.initialArea = Qt.rect(0, 0, 0, 0) + screenShotUrl = "" + paintBoard.reset() + tool.visible = false + tool.activeTool = "" + tool.markToolInfoVisible = false + hideTextEditor() + gifRecordMode = false + gifBar.visible = false + highlightRect = Qt.rect(0, 0, 0, 0) + textClickPending = false + textPressMoved = false + textClickAbsPoint = Qt.point(0, 0) + textClickLocalPoint = Qt.point(0, 0) + clearHotkeyGesture() + O_ScreenCapture.releaseDesktopSnapshot() + root.visibility = Window.Hidden; + } + + Timer { + id: hotkeyLongPressTimer + interval: 280 + repeat: false + onTriggered: { + if (hotkeyGestureArmed && hasSelectionArea()) { + hotkeyLongPressed = true + tool.visible = true + } + } + } + + FileDialog { + id: saveDialog + title: qsTr("Select save location") + currentFolder: O_ImageSaver.savePath + fileMode: FileDialog.SaveFile + nameFilters: ["PNG (*.png)", "JPG (*.jpg)", "BMP (*.bmp)"] + onAccepted: { + var success = O_ImageSaver.saveImage("data:image/png;base64,"+screenShotUrl, saveDialog.selectedFile) + if (success) { + console.log("Save success:", saveDialog.currentFolder) + trayHelper.showMessage(qsTr("Save success"), qsTr("Screenshot saved to file")) + } else { + console.log("Save failed") + trayHelper.showMessage(qsTr("Save failed"), qsTr("Failed to save screenshot file")) + } + resetArea() + } + } + + TZGifRecordBar { + id: gifBar + width: 260 + height: 44 + visible: false + z: 20 + captureRect: screenshotArea + x: { + let areaLeft = localScreenshotArea.width >= 0 ? localScreenshotArea.x : localScreenshotArea.x + localScreenshotArea.width + let preferX = areaLeft + Math.abs(localScreenshotArea.width) - width + return Math.max(0, Math.min(preferX, mainWin.width - width)) + } + y: { + let areaTop = localScreenshotArea.height >= 0 ? localScreenshotArea.y : localScreenshotArea.y + localScreenshotArea.height + let belowY = areaTop + Math.abs(localScreenshotArea.height) + 6 + return belowY + height > mainWin.height ? areaTop - height - 6 : belowY + } + onSignalCancel: { + gifBar.visible = false + gifRecordMode = false + tool.visible = true + } + onSignalRecordFinished: { + resetArea() + } + } + + Rectangle { + id: recordingBorder + visible: O_GifRecordVM.isRecording + // Draw border outside capture rect so it won't be included in recorded frames. + x: (localScreenshotArea.width >= 0 ? localScreenshotArea.x : localScreenshotArea.x + localScreenshotArea.width) - 2 + y: (localScreenshotArea.height >= 0 ? localScreenshotArea.y : localScreenshotArea.y + localScreenshotArea.height) - 2 + width: Math.abs(localScreenshotArea.width) + 4 + height: Math.abs(localScreenshotArea.height) + 4 + color: "transparent" + border.color: "#EF4444" + border.width: 2 + z: 10 + } + + PaintBoard { + id: paintBoard + anchors.fill: parent + z: 1 + enabled: tool.activeTool !== "" && tool.activeTool !== "text" + visible: captureUiReady && screenshotArea.width !== 0 && screenshotArea.height !== 0 + shapeType: tool.shapeType + penColor: markToolInfo.currentColor + penSize: markToolInfo.currentSize + annotationText: markToolInfo.annotationText + textBackgroundEnabled: markToolInfo.textBackgroundEnabled + mosaicBlurLevel: markToolInfo.mosaicBlurLevel + numberAutoIncrement: markToolInfo.numberAutoIncrement + numberValue: markToolInfo.numberValue + } + + Connections { + target: paintBoard + function onNumberValueChanged(value) { + markToolInfo.numberValue = value + } + } + + Rectangle { + id: screenshotRect + anchors.fill: parent + color: "transparent"; + focus: true + z: 2 + visible: captureUiReady && !gifRecordMode + + Shape { + id: shape + ShapePath { + id: path + strokeWidth: -1 + fillColor: "#99000000" + startX: 0; startY: 0 + // 澶栨锛堟暣涓獥鍙o級 + PathLine { x: mainWin.width; y: 0 } + PathLine { x: mainWin.width; y: mainWin.height } + PathLine { x: 0; y: mainWin.height } + PathLine { x: 0; y: 0 } + // 闀傜┖锛堥€夊尯锛屾湰鍦板潗鏍囷級 + PathMove { + x: localScreenshotArea.x + y: localScreenshotArea.y + } + PathLine { + x: localScreenshotArea.x + localScreenshotArea.width + y: localScreenshotArea.y + } + PathLine { + x: localScreenshotArea.x + localScreenshotArea.width + y: localScreenshotArea.y + localScreenshotArea.height + } + PathLine { + x: localScreenshotArea.x + y: localScreenshotArea.y + localScreenshotArea.height + } + PathLine { + x: localScreenshotArea.x + y: localScreenshotArea.y + } + } + } + + TZLable { + width: 80; height: 20 + visible: screenshotArea.width !== 0 && screenshotArea.height !== 0 + x: localScreenshotArea.width > 0 ? localScreenshotArea.x : localScreenshotArea.x + localScreenshotArea.width + y: localScreenshotArea.height > 0 ? localScreenshotArea.y - 25 : localScreenshotArea.y + localScreenshotArea.height - 25 + lableText: qsTr("%1 * %2").arg(Math.abs(screenshotArea.width)).arg(Math.abs(screenshotArea.height)) + } + + TZTool { + id: tool + width: 620; height: 44 + z: 30 + + // Toolbar position (local coordinates) + x: { + let areaLeft = localScreenshotArea.width >= 0 ? localScreenshotArea.x : localScreenshotArea.x + localScreenshotArea.width + let preferX = areaLeft + Math.abs(localScreenshotArea.width) - width + return Math.max(0, Math.min(preferX, mainWin.width - width)) + } + y: { + let areaTop = localScreenshotArea.height >= 0 ? localScreenshotArea.y : localScreenshotArea.y + localScreenshotArea.height + let belowY = areaTop + Math.abs(localScreenshotArea.height) + 6 + return belowY + height > mainWin.height ? areaTop - height - 6 : belowY + } + + onActiveToolChanged: { + if (activeTool !== "text" && textEditor.visible) { + hideTextEditor() + } + + if (activeTool === "mosaic" && !paintBoard.hasBackgroundImg()) { + Qt.callLater(function() { + O_ScreenCapture.grabToPaintBoard( + paintBoard, + Qt.rect(screenVirtualX, screenVirtualY, mainWin.width, mainWin.height) + ) + }) + } + } + + onSignalScreenshot: { + confirmSelectionToClipboard() + } + + onSignalCancel: { resetArea() } + + onSignalSave: { + root.visibility = Window.Hidden + Qt.callLater(function() { + screenShotUrl = O_ScreenCapture.captureRectToBase64(paintBoard, screenshotArea) + root.visibility = Window.Windowed + saveDialog.open() + }) + } + + onSignalSticky: { + root.visibility = Window.Hidden + Qt.callLater(function() { + var url = O_ScreenCapture.captureRectToStickyUrl(paintBoard, screenshotArea) + O_StickyViewModel.requestSticky(url, screenshotArea) + resetArea() + }) + } + + onSignalUndo: { paintBoard.undo() } + + onSignalGifRecord: { + gifRecordMode = true + tool.visible = false + gifBar.visible = true + Qt.callLater(function() { + O_GifRecordVM.startRecording( + screenshotArea.x, screenshotArea.y, + screenshotArea.width, screenshotArea.height + ) + }) + } + + onSignalOcr: { + var captureRect = screenshotArea + Qt.callLater(function() { + var img = O_ScreenCapture.captureRectToImage(captureRect) + O_OcrVM.recognize(img) + }) + } + } + + TZMarkToolInfo { + id: markToolInfo + visible: tool.visible && tool.markToolInfoVisible + z: 30 + x: tool.x + y: tool.y + tool.height + toolName: tool.activeTool + showColorRow: tool.activeTool !== "mosaic" + } + + Rectangle { + id: textEditor + visible: false + width: 260 + height: 88 + radius: 8 + color: "#FFFFFF" + border.color: "#CBD5E1" + border.width: 1 + z: 40 + + Column { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + Rectangle { + width: parent.width + height: 34 + radius: 6 + color: "#F8FAFC" + border.color: textInput.activeFocus ? "#3B82F6" : "#E2E8F0" + border.width: 1 + + TextInput { + id: textInput + anchors.fill: parent + anchors.margins: 8 + verticalAlignment: TextInput.AlignVCenter + color: "#0F172A" + font.pixelSize: 13 + selectByMouse: true + text: pendingTextValue + onAccepted: submitTextEditor() + } + } + + Row { + spacing: 8 + + Rectangle { + width: 72 + height: 28 + radius: 6 + color: cancelInputArea.containsMouse ? "#F1F5F9" : "transparent" + border.color: "#CBD5E1" + border.width: 1 + + Text { + anchors.centerIn: parent + text: qsTr("Cancel") + color: "#334155" + font.pixelSize: 12 + } + + MouseArea { + id: cancelInputArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: hideTextEditor() + } + } + + Rectangle { + width: 72 + height: 28 + radius: 6 + color: confirmInputArea.containsMouse ? "#2563EB" : "#3B82F6" + + Text { + anchors.centerIn: parent + text: qsTr("OK") + color: "#FFFFFF" + font.pixelSize: 12 + } + + MouseArea { + id: confirmInputArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: submitTextEditor() + } + } + } + } + } + + TZResizeHandleGroup { + id: resizeHandleGroup + // Handle control points use local coordinates + area: localScreenshotArea + initialArea: Qt.rect( + initialScreenshotArea.x - screenVirtualX, + initialScreenshotArea.y - screenVirtualY, + initialScreenshotArea.width, + initialScreenshotArea.height) + z: 2 + onAreaChanged: { + // 杞洖缁濆鍧愭爣 + screenshotArea = Qt.rect( + area.x + screenVirtualX, + area.y + screenVirtualY, + area.width, area.height) + } + onInitialAreaChanged: { + initialScreenshotArea = Qt.rect( + initialArea.x + screenVirtualX, + initialArea.y + screenVirtualY, + initialArea.width, initialArea.height) + } + } + } + + TZResizeHandleGroup { + id: gifResizeHandleGroup + area: localScreenshotArea + initialArea: Qt.rect( + initialScreenshotArea.x - screenVirtualX, + initialScreenshotArea.y - screenVirtualY, + initialScreenshotArea.width, + initialScreenshotArea.height) + visible: gifRecordMode && !gifAreaLocked + enabled: !gifAreaLocked + z: 15 + onAreaChanged: { + screenshotArea = Qt.rect( + area.x + screenVirtualX, + area.y + screenVirtualY, + area.width, area.height) + O_GifRecordVM.updateCaptureRect( + screenshotArea.x, screenshotArea.y, + screenshotArea.width, screenshotArea.height + ) + } + onInitialAreaChanged: { + initialScreenshotArea = Qt.rect( + initialArea.x + screenVirtualX, + initialArea.y + screenVirtualY, + initialArea.width, initialArea.height) + } + } + + // Window highlight rectangle (local coordinates) + Rectangle { + id: windowHighlight + visible: captureUiReady && screenshotArea.width === 0 && screenshotArea.height === 0 + && highlightRect.width > 0 && highlightRect.height > 0 + x: localHighlightRect.x + y: localHighlightRect.y + width: localHighlightRect.width + height: localHighlightRect.height + color: "transparent" + border.color: "#3B82F6" + border.width: 2 + z: 5 + + Rectangle { + anchors.fill: parent + color: "#153B82F6" + } + + Repeater { + model: [ + { ax: 0, ay: 0, bx: 1, by: 0, cx: 0, cy: 1 }, + { ax: parent.width, ay: 0, bx:-1, by: 0, cx: 0, cy: 1 }, + { ax: 0, ay: parent.height, bx: 1, by: 0, cx: 0, cy:-1 }, + { ax: parent.width, ay: parent.height, bx:-1, by: 0, cx: 0, cy:-1 } + ] + delegate: Item { + x: modelData.ax - (modelData.bx > 0 ? 0 : 3) + y: modelData.ay - (modelData.cy > 0 ? 0 : 3) + Rectangle { x:0; y:0; width:12; height:3; color:"#3B82F6"; transform: Scale { xScale: modelData.bx } } + Rectangle { x:0; y:0; width:3; height:12; color:"#3B82F6"; transform: Scale { yScale: modelData.cy } } + } + } + } + + TZMagnifyingGlass { + id: magnifier + width: 130 + height: 170 + z: 3 + color: "transparent" + // Hide magnifier in GIF mode to avoid recording it + visible: captureUiReady && mainWin.visible && !tool.visible && !gifRecordMode + } + + MouseArea { + id: mouseArea + anchors.fill: parent + enabled: captureUiReady + hoverEnabled: true + focus: true + cursorShape: gifRecordMode + ? (gifAreaLocked ? Qt.ArrowCursor : Qt.SizeAllCursor) + : Qt.CrossCursor + + onPressed: function(mouse) { + var ax = toAbsX(mouseX), ay = toAbsY(mouseY) + textClickPending = false + textPressMoved = false + if (gifRecordMode) { + if (gifAreaLocked) { + return + } + if (isPointInRect(screenshotArea, ax, ay)) { + offset.x = ax + offset.y = ay + initialScreenshotArea = screenshotArea + } + return + } + if (spaceHeld && hasSelectionArea()) { + offset.x = ax + offset.y = ay + initialScreenshotArea = screenshotArea + return + } + if (tool.activeTool === "text" && screenshotArea.width !== 0 && screenshotArea.height !== 0 + && isPointInRect(screenshotArea, ax, ay)) { + textClickPending = true + textPressMoved = false + textClickAbsPoint = Qt.point(ax, ay) + textClickLocalPoint = Qt.point(mouseX, mouseY) + offset.x = ax + offset.y = ay + initialScreenshotArea = screenshotArea + return + } + if (textEditor.visible) { + hideTextEditor() + } + handleMousePressed(ax, ay) + } + + onPositionChanged: function(mouse) { + var ax = toAbsX(mouseX), ay = toAbsY(mouseY) + if (pressed && textClickPending) { + var tdx = ax - textClickAbsPoint.x + var tdy = ay - textClickAbsPoint.y + if (Math.abs(tdx) > 3 || Math.abs(tdy) > 3) { + textPressMoved = true + textClickPending = false + if (textEditor.visible) + hideTextEditor() + } else { + return + } + } + if (gifRecordMode) { + if (gifAreaLocked) { + return + } + if (pressed && isPointInRect(screenshotArea, ax, ay)) { + var dx = ax - offset.x + var dy = ay - offset.y + screenshotArea = Qt.rect( + initialScreenshotArea.x + dx, + initialScreenshotArea.y + dy, + screenshotArea.width, screenshotArea.height) + O_GifRecordVM.updateCaptureRect( + screenshotArea.x, screenshotArea.y, + screenshotArea.width, screenshotArea.height + ) + } + return + } + if (pressed && spaceHeld && hasSelectionArea()) { + var sdx = ax - offset.x + var sdy = ay - offset.y + screenshotArea = Qt.rect( + initialScreenshotArea.x + sdx, + initialScreenshotArea.y + sdy, + screenshotArea.width, screenshotArea.height) + return + } + // Magnifier follows mouse in local coordinates + magnifier.magnifierMouseX = mouseX + magnifier.magnifierMouseY = mouseY + handleMouseMoved(ax, ay, pressed, mouse.modifiers) + } + + onReleased: function(mouse) { + if (textClickPending && !textPressMoved) { + openTextEditor(textClickLocalPoint.x, textClickLocalPoint.y) + textClickPending = false + textPressMoved = false + return + } + textClickPending = false + textPressMoved = false + if (gifRecordMode) { + if (gifAreaLocked) { + return + } + initialScreenshotArea = screenshotArea + gifResizeHandleGroup.initialArea = Qt.rect( + initialScreenshotArea.x - screenVirtualX, + initialScreenshotArea.y - screenVirtualY, + initialScreenshotArea.width, initialScreenshotArea.height) + return + } + handleMouseReleased() + } + + onWheel: function(wheel) { wheel.accepted = true } + + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Space) { + spaceHeld = true + event.accepted = true + return + } + if (event.key === Qt.Key_Escape) { + if (handleEscAction()) + event.accepted = true + return + } + } + + Keys.onReleased: function(event) { + if (event.key === Qt.Key_Space) { + spaceHeld = false + event.accepted = true + return + } + + if (hotkeyGestureArmed && keyMatchesToken(event.key, hotkeyMainToken)) { + hotkeyLongPressTimer.stop() + if (!hotkeyLongPressed && hasSelectionArea()) + confirmSelectionToClipboard() + clearHotkeyGesture() + event.accepted = true + } + } + } + onVisibilityChanged: + { + if(root.visibility === Window.Hidden) { + resizeHandleGroup.visible = false; + gifResizeHandleGroup.visible = false; + } + } + +} + + diff --git a/qml/main.qml b/qml/main.qml new file mode 100644 index 0000000..4b42316 --- /dev/null +++ b/qml/main.qml @@ -0,0 +1,233 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Shapes +import QtQuick.Layouts +import com.Tz.tray 1.0 + +TZWindow { + id: root + visibility: Window.Hidden + title: qsTr("TZshot") + color: "transparent" + flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint + + property var settingWinObj: null + property var aboutWinObj: null + + property var overlayList: [] + property var overlayComponent: null + + function _prepareSessionState() { + captureUiReady = false + root.opacity = 0.0 + screenshotArea = Qt.rect(0, 0, 0, 0) + initialScreenshotArea = Qt.rect(0, 0, 0, 0) + highlightRect = Qt.rect(0, 0, 0, 0) + screenShotUrl = "" + + paintBoard.reset() + tool.visible = false + tool.activeTool = "" + tool.markToolInfoVisible = false + + gifRecordMode = false + if (gifBarObj) + gifBarObj.visible = false + + if (typeof resizeHandleGroup !== "undefined") { + resizeHandleGroup.area = Qt.rect(0, 0, 0, 0) + resizeHandleGroup.initialArea = Qt.rect(0, 0, 0, 0) + resizeHandleGroup.visible = false + } + if (typeof gifResizeHandleGroup !== "undefined") { + gifResizeHandleGroup.area = Qt.rect(0, 0, 0, 0) + gifResizeHandleGroup.initialArea = Qt.rect(0, 0, 0, 0) + gifResizeHandleGroup.visible = false + } + + if (typeof hideTextEditor === "function") + hideTextEditor() + } + + function startScreenshot() { + root.visibility = Window.Hidden + _destroyOverlays() + _prepareSessionState() + + Qt.callLater(function() { + O_ScreenCapture.captureDesktop() + + var screens = Qt.application.screens + if (screens.length === 0) + return + + var primaryIdx = 0 + for (var i = 0; i < screens.length; i++) { + if (screens[i].virtualX === 0 && screens[i].virtualY === 0) { + primaryIdx = i + break + } + } + var primary = screens[primaryIdx] + + root.screenVirtualX = primary.virtualX + root.screenVirtualY = primary.virtualY + root.x = primary.virtualX + root.y = primary.virtualY + root.width = primary.width + root.height = primary.height + + O_ScreenCapture.grabToPaintBoard( + root.paintBoard, + Qt.rect(root.screenVirtualX, root.screenVirtualY, root.width, root.height) + ) + + if (overlayComponent === null) + overlayComponent = Qt.createComponent("qrc:/qml/TZScreenOverlay.qml") + + var newList = [] + for (var j = 0; j < screens.length; j++) { + if (j === primaryIdx) + continue + + var sc = screens[j] + if (overlayComponent.status === Component.Ready) { + var ov = overlayComponent.createObject(null, { + "delegate": root, + "screenVX": sc.virtualX, + "screenVY": sc.virtualY, + "x": sc.virtualX, + "y": sc.virtualY, + "width": sc.width, + "height": sc.height, + "visibility": Window.Windowed + }) + if (ov) { + ov.raise() + newList.push(ov) + } + } else if (overlayComponent.status === Component.Error) { + console.error("TZScreenOverlay load failed:", overlayComponent.errorString()) + } + } + overlayList = newList + + root.visibility = Window.Windowed + root.raise() + root.requestActivate() + Qt.callLater(function() { + root.captureUiReady = true + Qt.callLater(function() { + root.opacity = 1.0 + }) + }) + }) + } + + function _destroyOverlays() { + for (var i = 0; i < overlayList.length; i++) + overlayList[i].destroy() + overlayList = [] + } + + function resetArea() { + _destroyOverlays() + _prepareSessionState() + O_ScreenCapture.releaseDesktopSnapshot() + root.visibility = Window.Hidden + } + + function showAboutWin() { + if (aboutWinObj !== null) { + aboutWinObj.raise() + aboutWinObj.requestActivate() + return + } + var component = Qt.createComponent("qrc:/qml/Setting/TZAboutWin.qml") + if (component.status === Component.Ready) + aboutWinObj = component.createObject(null) + else if (component.status === Component.Error) + console.error("About window load failed:", component.errorString()) + + if (aboutWinObj !== null) { + aboutWinObj.visible = true + aboutWinObj.raise() + aboutWinObj.requestActivate() + aboutWinObj.closing.connect(function() { + aboutWinObj.destroy(100) + aboutWinObj = null + }) + } + } + + function showSettingWin() { + if (settingWinObj !== null) { + settingWinObj.raise() + settingWinObj.requestActivate() + return + } + var component = Qt.createComponent("qrc:/qml/Setting/TZSettingWin.qml") + if (component.status === Component.Ready) + settingWinObj = component.createObject(null) + else if (component.status === Component.Error) + console.error("Setting window load failed:", component.errorString()) + + if (settingWinObj !== null) { + settingWinObj.visible = true + settingWinObj.raise() + settingWinObj.requestActivate() + settingWinObj.closing.connect(function() { + settingWinObj.destroy(100) + settingWinObj = null + }) + } + } + + Connections { + target: O_StickyViewModel + function onStickyReady(imageUrl, imgRect) { + root.createStickyWindow(imageUrl, imgRect) + } + } + + Connections { + target: O_OcrVM + function onIsRecognizingChanged() { + if (O_OcrVM.isRecognizing) + root.createOcrResultWindow() + } + function onResultReady(text) { + root.createOcrResultWindow() + } + function onRecognizeFailed(errorMessage) { + trayHelper.showMessage(qsTr("OCR 识别失败"), errorMessage) + } + } + + TrayIconHelper { + id: trayHelper + function onTrayClicked(clickType) { + if (clickType === 0 || clickType === 2) + startScreenshot() + } + onScreenshotTriggered: startScreenshot() + onShowSettingTriggered: { showSettingWin() } + onShowAboutTriggered: { showAboutWin() } + onExitTriggered: Qt.quit() + } + + Component.onCompleted: { + trayHelper.setIcon(":/resource/img/tray_icon.svg") + trayHelper.show() + + O_GlobalShortcut.screenshotActivated.connect(function() { startScreenshot() }) + O_GlobalShortcut.screenshotSaveActivated.connect(function() { startScreenshot() }) + O_GlobalShortcut.stickyActivated.connect(function() { startScreenshot() }) + O_GlobalShortcut.toggleActivated.connect(function() { + if (root.visible) + resetArea() + else + startScreenshot() + }) + } +} diff --git a/resource/img/lc_arrow.svg b/resource/img/lc_arrow.svg new file mode 100644 index 0000000..70dc422 --- /dev/null +++ b/resource/img/lc_arrow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resource/img/lc_chat.svg b/resource/img/lc_chat.svg new file mode 100644 index 0000000..812ca34 --- /dev/null +++ b/resource/img/lc_chat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resource/img/lc_check.svg b/resource/img/lc_check.svg new file mode 100644 index 0000000..858ea6f --- /dev/null +++ b/resource/img/lc_check.svg @@ -0,0 +1,3 @@ + + + diff --git a/resource/img/lc_circle.svg b/resource/img/lc_circle.svg new file mode 100644 index 0000000..5326e94 --- /dev/null +++ b/resource/img/lc_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/resource/img/lc_close.svg b/resource/img/lc_close.svg new file mode 100644 index 0000000..982309c --- /dev/null +++ b/resource/img/lc_close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resource/img/lc_emoji.svg b/resource/img/lc_emoji.svg new file mode 100644 index 0000000..8a511b7 --- /dev/null +++ b/resource/img/lc_emoji.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resource/img/lc_file.svg b/resource/img/lc_file.svg new file mode 100644 index 0000000..3146f30 --- /dev/null +++ b/resource/img/lc_file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resource/img/lc_gif_record.svg b/resource/img/lc_gif_record.svg new file mode 100644 index 0000000..1453a5f --- /dev/null +++ b/resource/img/lc_gif_record.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/resource/img/lc_gif_stop.svg b/resource/img/lc_gif_stop.svg new file mode 100644 index 0000000..ff50adf --- /dev/null +++ b/resource/img/lc_gif_stop.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resource/img/lc_highlighter.svg b/resource/img/lc_highlighter.svg new file mode 100644 index 0000000..b2d6423 --- /dev/null +++ b/resource/img/lc_highlighter.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/resource/img/lc_list_ordered.svg b/resource/img/lc_list_ordered.svg new file mode 100644 index 0000000..3f31db7 --- /dev/null +++ b/resource/img/lc_list_ordered.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/resource/img/lc_mosaic.svg b/resource/img/lc_mosaic.svg new file mode 100644 index 0000000..219d396 --- /dev/null +++ b/resource/img/lc_mosaic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/resource/img/lc_ocr.svg b/resource/img/lc_ocr.svg new file mode 100644 index 0000000..a3e5ee7 --- /dev/null +++ b/resource/img/lc_ocr.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/resource/img/lc_pencil.svg b/resource/img/lc_pencil.svg new file mode 100644 index 0000000..72bd617 --- /dev/null +++ b/resource/img/lc_pencil.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resource/img/lc_pin.svg b/resource/img/lc_pin.svg new file mode 100644 index 0000000..dbf1f01 --- /dev/null +++ b/resource/img/lc_pin.svg @@ -0,0 +1 @@ + diff --git a/resource/img/lc_save.svg b/resource/img/lc_save.svg new file mode 100644 index 0000000..f0ea54f --- /dev/null +++ b/resource/img/lc_save.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/resource/img/lc_send.svg b/resource/img/lc_send.svg new file mode 100644 index 0000000..25faa4d --- /dev/null +++ b/resource/img/lc_send.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resource/img/lc_square.svg b/resource/img/lc_square.svg new file mode 100644 index 0000000..71602b8 --- /dev/null +++ b/resource/img/lc_square.svg @@ -0,0 +1,3 @@ + + + diff --git a/resource/img/lc_text.svg b/resource/img/lc_text.svg new file mode 100644 index 0000000..8bbed08 --- /dev/null +++ b/resource/img/lc_text.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/resource/img/lc_undo.svg b/resource/img/lc_undo.svg new file mode 100644 index 0000000..fd14d2d --- /dev/null +++ b/resource/img/lc_undo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resource/img/lc_x.svg b/resource/img/lc_x.svg new file mode 100644 index 0000000..5dfa43f --- /dev/null +++ b/resource/img/lc_x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resource/img/tray_icon.svg b/resource/img/tray_icon.svg new file mode 100644 index 0000000..3f0fb4b --- /dev/null +++ b/resource/img/tray_icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/src/ai_call/ai_call_base.cpp b/src/ai_call/ai_call_base.cpp new file mode 100644 index 0000000..34941c7 --- /dev/null +++ b/src/ai_call/ai_call_base.cpp @@ -0,0 +1,112 @@ +#include "ai_call_base.h" + +AICallBase::AICallBase(QObject *parent) + : QObject(parent) + , m_lastErrorType(AIErrorType::NoError) + , m_timeout(30000) // 默认30秒超时 + , m_timeoutTimer(new QTimer(this)) + , m_currentReply(nullptr) +{ + // 初始化网络管理器 + m_networkManager = new QNetworkAccessManager(this); + + // 连接超时定时器 + m_timeoutTimer->setSingleShot(true); + connect(m_timeoutTimer, &QTimer::timeout, this, &AICallBase::onRequestTimeout); +} + +AICallBase::~AICallBase() +{ + cancelRequest(); +} + +void AICallBase::setTimeout(int msec) +{ + if (msec > 0) { + m_timeout = msec; + } +} + +QString AICallBase::lastErrorString() const +{ + return m_lastErrorString; +} + +AIErrorType AICallBase::lastErrorType() const +{ + return m_lastErrorType; +} + +void AICallBase::cancelRequest() +{ + // 停止超时定时器 + m_timeoutTimer->stop(); + + // 取消当前请求 + if (m_currentReply) { + m_currentReply->abort(); + m_currentReply->deleteLater(); + m_currentReply = nullptr; + } + + // 重置错误信息 + m_lastErrorType = AIErrorType::NoError; + m_lastErrorString.clear(); +} + +void AICallBase::setError(AIErrorType errorType, const QString &errorMsg) +{ + m_lastErrorType = errorType; + m_lastErrorString = errorMsg; + emit requestFailed(errorType, errorMsg); +} + +void AICallBase::onReplyFinished(QNetworkReply *reply) +{ + // 停止超时定时器 + m_timeoutTimer->stop(); + + // 清理Reply + reply->deleteLater(); + m_currentReply = nullptr; + + if (reply->error() != QNetworkReply::NoError) { + setError(AIErrorType::NetworkError, reply->errorString()); + return; + } + + // 读取响应数据 + QByteArray responseData = reply->readAll(); + + // 解析响应(子类实现) + QString result; + try { + result = parseResponse(responseData); + } catch (const std::exception &e) { + setError(AIErrorType::ApiError, QString("响应解析失败:%1").arg(e.what())); + return; + } catch (...) { + setError(AIErrorType::ApiError, "响应解析失败:未知错误"); + return; + } + + // 发送成功信号 + emit requestSuccess(result); +} + +void AICallBase::onReplyError(QNetworkReply::NetworkError error) +{ + Q_UNUSED(error); + // 错误处理在onReplyFinished中统一处理 +} + +void AICallBase::onRequestTimeout() +{ + if (m_currentReply) { + m_currentReply->abort(); + m_currentReply->deleteLater(); + m_currentReply = nullptr; + } + + setError(AIErrorType::TimeoutError, QString("请求超时(%1毫秒)").arg(m_timeout)); +} diff --git a/src/ai_call/ai_call_base.h b/src/ai_call/ai_call_base.h new file mode 100644 index 0000000..6d04d2b --- /dev/null +++ b/src/ai_call/ai_call_base.h @@ -0,0 +1,92 @@ +#ifndef AICALLBASE_H +#define AICALLBASE_H + +#include +#include +#include +#include +#include +#include +#include +#include + +// AI 调用错误类型枚举 +enum class AIErrorType { + NoError, // 无错误 + NetworkError, // 网络错误 + ApiError, // API 返回错误 + TimeoutError, // 请求超时 + ParamError // 参数错误 +}; + +// AI 调用基类(抽象类) +class AICallBase : public QObject +{ + Q_OBJECT +public: + explicit AICallBase(QObject *parent = nullptr); + virtual ~AICallBase(); + + // 设置请求超时时间(默认30秒) + void setTimeout(int msec); + + // 获取最后一次错误信息 + QString lastErrorString() const; + + // 获取最后一次错误类型 + AIErrorType lastErrorType() const; + + /** + * @brief 发送AI请求(核心接口,子类实现) + * @param prompt 提示词/问题 + * @param params 额外参数(不同AI接口的自定义参数) + * @return 是否成功发起请求 + */ + virtual bool sendRequest(const QString &prompt, const QJsonObject ¶ms = QJsonObject()) = 0; + + /** + * @brief 取消当前请求 + */ + void cancelRequest(); + +signals: + // 请求成功,返回响应内容 + void requestSuccess(const QString &response); + // 请求失败,返回错误类型和错误信息 + void requestFailed(AIErrorType errorType, const QString &errorMsg); + // 请求进度(可选,如流式响应) + void requestProgress(int progress); + +protected: + // 构建请求头(子类实现) + virtual QNetworkRequest buildRequest() = 0; + + // 构建请求体(子类实现) + virtual QByteArray buildRequestBody(const QString &prompt, const QJsonObject ¶ms) = 0; + + // 解析响应数据(子类实现) + virtual QString parseResponse(const QByteArray &responseData) = 0; + + // 设置错误信息 + void setError(AIErrorType errorType, const QString &errorMsg); + + // 网络请求管理器(所有子类共享) + QNetworkAccessManager *m_networkManager; + +public slots: + // 网络响应处理 + virtual void onReplyFinished(QNetworkReply *reply); + // 网络错误处理 + virtual void onReplyError(QNetworkReply::NetworkError error); + // 请求超时处理 + virtual void onRequestTimeout(); + +public: + QString m_lastErrorString; // 最后一次错误信息 + AIErrorType m_lastErrorType; // 最后一次错误类型 + int m_timeout; // 超时时间(毫秒) + QTimer *m_timeoutTimer; // 超时定时器 + QNetworkReply *m_currentReply; // 当前请求的Reply +}; + +#endif // AICALLBASE_H diff --git a/src/ai_call/ai_type.h b/src/ai_call/ai_type.h new file mode 100644 index 0000000..1ef847f --- /dev/null +++ b/src/ai_call/ai_type.h @@ -0,0 +1,14 @@ +#ifndef AI_TYPE_H +#define AI_TYPE_H + +// AI 模型类型枚举 +// 值与 QML 设置页面 aiModel ListModel 的索引一一对应: +// 0 → 阿里云百炼(Qwen) +// 1 → 火山引擎(Seedream) +enum AIType +{ + QWENAI = 0, + SEEDREAMAI = 1 +}; + +#endif // AI_TYPE_H diff --git a/src/ai_call/qwen_ai_call.cpp b/src/ai_call/qwen_ai_call.cpp new file mode 100644 index 0000000..a287fe0 --- /dev/null +++ b/src/ai_call/qwen_ai_call.cpp @@ -0,0 +1,143 @@ +#include "qwen_ai_call.h" +#include +#include + +QwenAICall::QwenAICall(const QString &apiKey, QObject *parent) + : AICallBase(parent) + , m_apiKey(apiKey) + , m_apiUrl("https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation") +{ + +} + +bool QwenAICall::sendRequest(const QString &prompt, const QJsonObject ¶ms) +{ + // 参数校验 + if (m_apiKey.isEmpty()) { + setError(AIErrorType::ParamError, "API Key 不能为空"); + return false; + } + + if (prompt.isEmpty()) { + setError(AIErrorType::ParamError, "提示词不能为空"); + return false; + } + + // 取消之前的请求 + cancelRequest(); + + // 构建请求 + QNetworkRequest request = buildRequest(); + QByteArray requestBody = buildRequestBody(prompt, params); + // 发送POST请求 + m_currentReply = m_networkManager->post(request, requestBody); + // 用局部变量捕获当前 reply,避免 cancelRequest() 将 m_currentReply 置空后 + // lambda 中持有悬空指针导致 use-after-free + QNetworkReply *reply = m_currentReply; + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + this->AICallBase::onReplyFinished(reply); + }); + connect(reply, &QNetworkReply::errorOccurred, this, [this](QNetworkReply::NetworkError errorCode) { + this->AICallBase::onReplyError(errorCode); + }); + + // 启动超时定时器 + m_timeoutTimer->start(m_timeout); + + return true; +} + +void QwenAICall::setApiUrl(const QString &url) +{ + if (!url.isEmpty()) { + m_apiUrl = url; + } +} + +QNetworkRequest QwenAICall::buildRequest() +{ + QNetworkRequest request; + request.setUrl(QUrl(m_apiUrl)); + + // 设置请求头 + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", QString("Bearer %1").arg(m_apiKey).toUtf8()); + // 可选:添加User-Agent(部分API要求) + request.setRawHeader("User-Agent", "Mozilla/5.0 (X11; Linux aarch64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.200 Safari/537.36 Qaxbrowser"); + return request; +} + +QByteArray QwenAICall::buildRequestBody(const QString &prompt, const QJsonObject ¶ms) +{ + QJsonObject requestJson; + + // 默认参数 + requestJson["model"] = "wan2.6-image"; + + // 构建消息体 + QJsonObject input; + QJsonArray messages; + QJsonArray content; + QJsonObject message; + QJsonObject promptInfo; + QJsonObject imgInfo; + message["role"] = "user"; + promptInfo["text"] = prompt; + imgInfo["image"] = params["img"]; + content.append(promptInfo); + content.append(imgInfo); + message["content"] = content; + messages.append(message); + input["messages"] = messages; + requestJson["input"] = input; + + // 转换为JSON字节数组 + return QJsonDocument(requestJson).toJson(QJsonDocument::Compact); +} + +QString QwenAICall::parseResponse(const QByteArray &responseData) +{ + QJsonParseError parseError; + QJsonDocument doc = QJsonDocument::fromJson(responseData, &parseError); + + if (parseError.error != QJsonParseError::NoError) { + throw std::runtime_error(parseError.errorString().toStdString()); + } + + if (!doc.isObject()) { + throw std::runtime_error("响应不是有效的JSON对象"); + } + + QJsonObject rootObj = doc.object(); + + // 检查API错误 + if (rootObj.contains("error")) { + QJsonObject errorObj = rootObj["error"].toObject(); + QString errorMsg = errorObj["message"].toString("未知API错误"); + throw std::runtime_error(errorMsg.toStdString()); + } + + // 解析返回内容 + QJsonObject data = rootObj["output"].toObject(); + QJsonArray choices = data["choices"].toArray(); + if (choices.isEmpty()) { + throw std::runtime_error("响应中无有效内容"); + } + + QJsonObject choiceObj = choices.first().toObject(); + QJsonObject messageObj = choiceObj["message"].toObject(); + QJsonArray content = messageObj["content"].toArray(); + QJsonObject imgdata = content.first().toObject(); + QString img = imgdata["image"].toString(); + if (img.isEmpty()) { + throw std::runtime_error("响应内容为空"); + } + + return img; +} + +bool QwenAICall::setApiKey(QString apiKey) +{ + m_apiKey = apiKey; + return true; +} diff --git a/src/ai_call/qwen_ai_call.h b/src/ai_call/qwen_ai_call.h new file mode 100644 index 0000000..9a62d54 --- /dev/null +++ b/src/ai_call/qwen_ai_call.h @@ -0,0 +1,31 @@ +#ifndef QWENAICALL_H +#define QWENAICALL_H + +#include +#include "ai_call_base.h" + +class QwenAICall:public AICallBase +{ + Q_OBJECT + +public: + explicit QwenAICall(const QString &apiKey, QObject *parent = nullptr); + + // 实现基类纯虚函数 + bool sendRequest(const QString &prompt, const QJsonObject ¶ms = QJsonObject()) override; + + // 设置API地址(默认OpenAI官方地址,可替换为代理地址) + void setApiUrl(const QString &url); + +public: + QNetworkRequest buildRequest() override; + QByteArray buildRequestBody(const QString &prompt, const QJsonObject ¶ms) override; + QString parseResponse(const QByteArray &responseData) override; +public: + bool setApiKey(QString apiKey); +private: + QString m_apiKey; // API Key + QString m_apiUrl; +}; + +#endif // QWENAICALL_H diff --git a/src/ai_call/seedream_ai_call.cpp b/src/ai_call/seedream_ai_call.cpp new file mode 100644 index 0000000..65a36bf --- /dev/null +++ b/src/ai_call/seedream_ai_call.cpp @@ -0,0 +1,174 @@ +#include "seedream_ai_call.h" +#include +#include +#include + +// 默认模型名称(火山引擎方舟 Seedream 5.0) +static const QString kDefaultModel = "doubao-seedream-5-0-260128"; +static const QString kDefaultApiUrl = "https://ark.cn-beijing.volces.com/api/v3/images/generations"; + +SeedreamAiCall::SeedreamAiCall(const QString &apiKey, QObject *parent) + : AICallBase(parent) + , m_apiKey(apiKey) + , m_apiUrl(kDefaultApiUrl) + , m_model(kDefaultModel) +{ + // 图片生成耗时较长,默认超时 150 秒 + m_timeout = 150000; +} + +void SeedreamAiCall::setApiKey(const QString &key) +{ + if (!key.isEmpty()) + m_apiKey = key; +} + +void SeedreamAiCall::setModel(const QString &model) +{ + if (!model.isEmpty()) + m_model = model; +} + +void SeedreamAiCall::setApiUrl(const QString &url) +{ + if (!url.isEmpty()) + m_apiUrl = url; +} + +bool SeedreamAiCall::sendRequest(const QString &prompt, const QJsonObject ¶ms) +{ + if (m_apiKey.isEmpty()) { + setError(AIErrorType::ParamError, "API Key 不能为空"); + return false; + } + if (prompt.isEmpty()) { + setError(AIErrorType::ParamError, "提示词不能为空"); + return false; + } + + cancelRequest(); + + QNetworkRequest request = buildRequest(); + QByteArray body = buildRequestBody(prompt, params); + if (body.isEmpty()) + return false; + + m_currentReply = m_networkManager->post(request, body); + QNetworkReply *reply = m_currentReply; + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + this->AICallBase::onReplyFinished(reply); + }); + connect(reply, &QNetworkReply::errorOccurred, this, + [this](QNetworkReply::NetworkError errorCode) { + this->AICallBase::onReplyError(errorCode); + }); + + m_timeoutTimer->start(m_timeout); + return true; +} + +// ── 构建 HTTP 请求头 ────────────────────────────────────── +QNetworkRequest SeedreamAiCall::buildRequest() +{ + QNetworkRequest request; + request.setUrl(QUrl(m_apiUrl)); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", + QStringLiteral("Bearer %1").arg(m_apiKey).toUtf8()); + return request; +} + +// ── 构建请求体 ──────────────────────────────────────────── +// 请求体格式(文生图 / 图生图统一端点): +// { +// "model": "doubao-seedream-5-0-260128", // 必填 +// "prompt": "<用户提示词>", // 必填 +// "image": "", // 可选,有则为图生图 +// "size": "2K", // 可选,如 "1K"/"2K"/"4K" 或 "WxH" +// "output_format": "png", // 可选,"png"/"jpeg"/"webp" +// "watermark": false, // 可选 +// "seed": 12345 // 可选 +// } +QByteArray SeedreamAiCall::buildRequestBody(const QString &prompt, const QJsonObject ¶ms) +{ + QJsonObject body; + body["model"] = m_model; + body["prompt"] = prompt; + + // 可选:参考图片(字符串,非数组) + QString img = params.value("img").toString(); + if (!img.isEmpty()) + body["image"] = img; + + // 可选:图片尺寸,如 "2K"、"1024x1024" + QString size = params.value("size").toString(); + if (!size.isEmpty()) + body["size"] = size; + + // 可选:输出格式,默认 "png" + body["output_format"] = params.value("output_format").toString("png"); + + // 可选:水印,默认 false + body["watermark"] = params.value("watermark").toBool(false); + + // 可选:随机种子(>= 0 时才写入) + if (params.contains("seed")) { + int seed = params.value("seed").toInt(-1); + if (seed >= 0) + body["seed"] = seed; + } + + const QByteArray json = QJsonDocument(body).toJson(QJsonDocument::Compact); + //qDebug() << "[SeedreamAiCall] 请求体:" << json; + return json; +} + +// ── 解析响应 ────────────────────────────────────────────── +// 成功响应格式: +// { +// "model": "doubao-seedream-5-0-260128", +// "created": 1757323224, +// "data": [ +// { "url": "https://...", "size": "1760x2368" } +// ], +// "usage": { "generated_images": 1, "output_tokens": 16280, "total_tokens": 16280 } +// } +// 错误响应格式: +// { +// "error": { "code": "...", "message": "...", "param": null, "type": "..." } +// } +QString SeedreamAiCall::parseResponse(const QByteArray &responseData) +{ + QJsonParseError parseError; + QJsonDocument doc = QJsonDocument::fromJson(responseData, &parseError); + + if (parseError.error != QJsonParseError::NoError) + throw std::runtime_error(parseError.errorString().toStdString()); + + if (!doc.isObject()) + throw std::runtime_error("响应不是有效的 JSON 对象"); + + QJsonObject root = doc.object(); + + // API 层错误 + if (root.contains("error")) { + QJsonObject err = root["error"].toObject(); + QString code = err["code"].toString(); + QString msg = err["message"].toString("未知 API 错误"); + throw std::runtime_error( + QStringLiteral("[%1] %2").arg(code, msg).toStdString()); + } + + QJsonArray data = root["data"].toArray(); + if (data.isEmpty()) + throw std::runtime_error("响应中 data 数组为空"); + + QString url = data.first().toObject()["url"].toString(); + if (url.isEmpty()) + throw std::runtime_error("响应中 url 字段为空"); + + qDebug() << "[SeedreamAiCall] 生成图片 URL:" << url; + return url; +} + diff --git a/src/ai_call/seedream_ai_call.h b/src/ai_call/seedream_ai_call.h new file mode 100644 index 0000000..4ccbce8 --- /dev/null +++ b/src/ai_call/seedream_ai_call.h @@ -0,0 +1,73 @@ +#ifndef SEEDREAM_AI_CALL_H +#define SEEDREAM_AI_CALL_H + +#include +#include "ai_call_base.h" + +// 图片生成请求参数(对应火山引擎方舟 /api/v3/images/generations) +struct SeedreamRequestParams { + // 参考图片 URL 或 Base64(image-to-image 时传入;纯文生图可留空) + QString imageUrl; + + // 图片尺寸,格式 "宽x高",如 "1024x1024",默认空表示由模型决定 + QString size; + + // 生成数量,默认 1,最大值视模型而定 + int n = 1; + + // 输出格式:"png" / "jpeg" / "webp",默认 "png" + QString outputFormat = "png"; + + // 是否添加水印,默认 false + bool watermark = false; + + // 引导强度(图生图时有效),范围 0.0~1.0,默认 0.5 + double guidanceScale = 0.5; + + // 随机种子,-1 表示随机 + int seed = -1; +}; + +class SeedreamAiCall : public AICallBase +{ + Q_OBJECT + +public: + explicit SeedreamAiCall(const QString &apiKey, + QObject *parent = nullptr); + + // 设置 API Key(覆盖构造时传入的值) + void setApiKey(const QString &key); + + // 设置模型名称,默认 "doubao-seedream-5-0-260128" + void setModel(const QString &model); + + // 覆盖 API 地址(一般不需要修改) + void setApiUrl(const QString &url); + + // 发起请求 + // prompt : 文字提示词 + // params : QJsonObject,支持以下 key: + // "img" - 参考图片 URL/Base64(图生图) + // "size" - 尺寸字符串 + // "n" - 生成数量 + // "output_format" - 输出格式 + // "watermark" - bool + // "guidance_scale"- double + // "seed" - int + bool sendRequest(const QString &prompt, + const QJsonObject ¶ms = QJsonObject()) override; + +protected: + QNetworkRequest buildRequest() override; + QByteArray buildRequestBody(const QString &prompt, + const QJsonObject ¶ms) override; + QString parseResponse(const QByteArray &responseData) override; + +private: + QString m_apiKey; + QString m_apiUrl; + QString m_model; +}; + +#endif // SEEDREAM_AI_CALL_H diff --git a/src/gif_encoder/gif.h b/src/gif_encoder/gif.h new file mode 100644 index 0000000..bdcd133 --- /dev/null +++ b/src/gif_encoder/gif.h @@ -0,0 +1,349 @@ +// gif.h - GIF encoder single-header library +// Based on the public domain work by Charlie Tangora (gif-h) +// Modified for Qt integration with RGBA transparency support +// +// Usage: +// GifWriter g; +// GifBegin(&g, "output.gif", width, height, delay, transparentIndex); +// GifWriteFrame(&g, rgba_data, width, height, delay, 8, transparentIndex); +// GifEnd(&g); +// +// delay is in units of 10ms (so delay=2 means 20ms = 50fps, delay=7 = 70ms ~15fps) + +#ifndef GIF_H +#define GIF_H + +#include +#include +#include +#include + +// ----- Palette/Color quantization ----- + +static const int kGifTransIndex = 0; + +struct GifPalette { + int bitDepth; + uint8_t r[256]; + uint8_t g[256]; + uint8_t b[256]; + // k-d tree over RGB space for fast nearest-color lookup + uint8_t treeSplitElt[255]; + uint8_t treeSplit[255]; +}; + +// walks the k-d tree to find the best-matching color +static int GifGetClosestColor(const GifPalette* pPal, int r, int g, int b) { + int bestDiff = 1000000; + int bestInd = 1; + for (int ii = 1; ii < (1 << pPal->bitDepth); ++ii) { + int dr = (int)pPal->r[ii] - r; + int dg = (int)pPal->g[ii] - g; + int db = (int)pPal->b[ii] - b; + int diff = dr*dr + dg*dg + db*db; + if (diff < bestDiff) { + bestDiff = diff; + bestInd = ii; + } + } + return bestInd; +} + +// simple median-cut palette generation +static void GifMakePalette(const uint8_t* lastFrame, const uint8_t* nextFrame, + uint32_t width, uint32_t height, + int bitDepth, bool buildForDither, + GifPalette* pPal) { + pPal->bitDepth = bitDepth; + + // histogram + int numColors = 1 << bitDepth; + // Build a simple palette from the image data + // For simplicity we use a fixed 6-6-6 color cube for index 1+ + // and reserve index 0 for transparency + + // 6x6x6 = 216 colors, fits in 8-bit palette (256 entries, index 0 = transparent) + int ri = 0, gi = 0, bi = 0; + for (int i = 1; i < numColors && i < 217; ++i) { + pPal->r[i] = (uint8_t)(ri * 51); + pPal->g[i] = (uint8_t)(gi * 51); + pPal->b[i] = (uint8_t)(bi * 51); + ri++; + if (ri >= 6) { ri = 0; gi++; } + if (gi >= 6) { gi = 0; bi++; } + } + // fill remaining with greyscale + for (int i = 217; i < numColors; ++i) { + int v = (i - 217) * 256 / (numColors - 217); + pPal->r[i] = pPal->g[i] = pPal->b[i] = (uint8_t)v; + } + // index 0 is transparent (set to black) + pPal->r[0] = pPal->g[0] = pPal->b[0] = 0; + + (void)lastFrame; (void)nextFrame; (void)width; (void)height; (void)buildForDither; +} + +// ----- LZW Compression ----- + +static const int kGifMaxCodeLen = 12; + +struct GifLzwNode { + uint16_t m_next[256]; +}; + +static void GifWriteCode(FILE* f, uint32_t& curCode, uint32_t& curCodeSize, + uint8_t* buf, uint32_t& bufLen, + uint32_t code) { + curCode |= code << curCodeSize; + curCodeSize += (uint32_t)(code >= (1ul << (curCodeSize - 1)) ? 1 : 0); + // flush byte + while (curCodeSize >= 8) { + buf[bufLen++] = (uint8_t)(curCode & 0xff); + curCode >>= 8; + curCodeSize -= 8; + if (bufLen == 255) { + fputc(255, f); + fwrite(buf, 1, 255, f); + bufLen = 0; + } + } +} + +// Full LZW encoder +static void GifLzwCompress(FILE* f, const uint8_t* image, uint32_t width, uint32_t height, + int bitDepth, int transparent) { + int clearCode = 1 << bitDepth; + int eofCode = clearCode + 1; + int maxCode = eofCode + 1; + int codeSize = bitDepth + 1; + + fputc(bitDepth, f); // LZW minimum code size + + // allocate trie + GifLzwNode* codetree = (GifLzwNode*)calloc(4096, sizeof(GifLzwNode)); + + uint32_t curCode = 0, curCodeSize = (uint32_t)codeSize; + uint8_t buf[256]; + uint32_t bufLen = 0; + + // emit clear code + curCode |= (uint32_t)clearCode << curCodeSize; + curCodeSize += (uint32_t)codeSize; + while (curCodeSize >= 8) { + buf[bufLen++] = (uint8_t)(curCode & 0xff); + curCode >>= 8; curCodeSize -= 8; + if (bufLen == 255) { fputc(255, f); fwrite(buf, 1, 255, f); bufLen = 0; } + } + + int curEntry = -1; + for (uint32_t yy = 0; yy < height; ++yy) { + for (uint32_t xx = 0; xx < width; ++xx) { + const uint8_t* pixel = image + (yy * width + xx) * 4; + uint8_t alpha = pixel[3]; + int nextValue; + if (alpha < 128) { + nextValue = kGifTransIndex; // transparent + } else { + // find best palette entry (skip index 0 which is transparent) + int r = pixel[0], g = pixel[1], b = pixel[2]; + // Simple 6-6-6 cube lookup + int ri2 = r / 51; if (ri2 > 5) ri2 = 5; + int gi2 = g / 51; if (gi2 > 5) gi2 = 5; + int bi2 = b / 51; if (bi2 > 5) bi2 = 5; + nextValue = 1 + ri2 + gi2 * 6 + bi2 * 36; + if (nextValue >= (1 << bitDepth)) nextValue = (1 << bitDepth) - 1; + if (nextValue == kGifTransIndex) nextValue = 1; + } + + if (curEntry < 0) { + curEntry = nextValue; + continue; + } + + if (codetree[curEntry].m_next[nextValue]) { + curEntry = codetree[curEntry].m_next[nextValue]; + } else { + // emit code for curEntry + int emitCode = curEntry; + curCode |= (uint32_t)emitCode << curCodeSize; + curCodeSize += (uint32_t)codeSize; + while (curCodeSize >= 8) { + buf[bufLen++] = (uint8_t)(curCode & 0xff); + curCode >>= 8; curCodeSize -= 8; + if (bufLen == 255) { fputc(255, f); fwrite(buf, 1, 255, f); bufLen = 0; } + } + + if (maxCode == (1 << codeSize)) codeSize++; + + if (maxCode == 4095) { + // emit clear code to reset + curCode |= (uint32_t)clearCode << curCodeSize; + curCodeSize += (uint32_t)codeSize; + while (curCodeSize >= 8) { + buf[bufLen++] = (uint8_t)(curCode & 0xff); + curCode >>= 8; curCodeSize -= 8; + if (bufLen == 255) { fputc(255, f); fwrite(buf, 1, 255, f); bufLen = 0; } + } + // reset trie + memset(codetree, 0, 4096 * sizeof(GifLzwNode)); + maxCode = eofCode + 1; + codeSize = bitDepth + 1; + } else { + codetree[curEntry].m_next[nextValue] = (uint16_t)maxCode++; + } + curEntry = nextValue; + } + } + } + + // emit final code + if (curEntry >= 0) { + curCode |= (uint32_t)curEntry << curCodeSize; + curCodeSize += (uint32_t)codeSize; + while (curCodeSize >= 8) { + buf[bufLen++] = (uint8_t)(curCode & 0xff); + curCode >>= 8; curCodeSize -= 8; + if (bufLen == 255) { fputc(255, f); fwrite(buf, 1, 255, f); bufLen = 0; } + } + } + + // emit EOF + curCode |= (uint32_t)eofCode << curCodeSize; + curCodeSize += (uint32_t)codeSize; + while (curCodeSize >= 8) { + buf[bufLen++] = (uint8_t)(curCode & 0xff); + curCode >>= 8; curCodeSize -= 8; + if (bufLen == 255) { fputc(255, f); fwrite(buf, 1, 255, f); bufLen = 0; } + } + + // flush remaining bits + if (curCodeSize > 0) { + buf[bufLen++] = (uint8_t)(curCode & 0xff); + } + if (bufLen > 0) { + fputc((int)bufLen, f); + fwrite(buf, 1, bufLen, f); + } + + fputc(0, f); // block terminator + + free(codetree); + (void)transparent; +} + +// ----- GIF Writer ----- + +struct GifWriter { + FILE* f; + uint32_t width; + uint32_t height; + int bitDepth; + bool firstFrame; + int transparentIndex; // palette index reserved for transparency (-1 = none) +}; + +static bool GifBegin(GifWriter* writer, const char* filename, + uint32_t width, uint32_t height, + uint32_t delay, + int bitDepth = 8, + bool dither = false) { + writer->f = fopen(filename, "wb"); + if (!writer->f) return false; + + writer->width = width; + writer->height = height; + writer->bitDepth = bitDepth; + writer->firstFrame = true; + writer->transparentIndex = kGifTransIndex; + + // GIF header + fputs("GIF89a", writer->f); + + // Logical screen descriptor + uint16_t w16 = (uint16_t)width; + uint16_t h16 = (uint16_t)height; + fwrite(&w16, 2, 1, writer->f); + fwrite(&h16, 2, 1, writer->f); + + // Global color table flag=0 (we use local), color res=7, sort=0, size=0 + fputc(0x70, writer->f); // packed field: no global CT + fputc(0, writer->f); // background color index + fputc(0, writer->f); // pixel aspect ratio + + // Netscape loop extension (loop forever) + fputc(0x21, writer->f); // Extension introducer + fputc(0xff, writer->f); // Application extension + fputc(11, writer->f); // block size + fputs("NETSCAPE2.0", writer->f); + fputc(3, writer->f); // sub-block size + fputc(1, writer->f); // sub-block ID + fputc(0, writer->f); // loop count low byte (0 = infinite) + fputc(0, writer->f); // loop count high byte + fputc(0, writer->f); // block terminator + + (void)delay; (void)dither; + return true; +} + +static bool GifWriteFrame(GifWriter* writer, const uint8_t* image, + uint32_t width, uint32_t height, + uint32_t delay, + int bitDepth = 8, + bool dither = false) { + if (!writer->f) return false; + + FILE* f = writer->f; + GifPalette pal; + GifMakePalette(nullptr, image, width, height, bitDepth, dither, &pal); + + // Graphic Control Extension (for transparency + delay) + fputc(0x21, f); // extension introducer + fputc(0xf9, f); // graphic control label + fputc(0x04, f); // block size + + // packed: reserved=0, disposal=1(leave in place), user input=0, transparent=1 + fputc(0x05, f); // disposal=1, transparent flag=1 + + uint16_t delay16 = (uint16_t)delay; + fwrite(&delay16, 2, 1, f); // delay in 1/100 sec units + + fputc((uint8_t)kGifTransIndex, f); // transparent color index + fputc(0, f); // block terminator + + // Image descriptor + fputc(0x2c, f); // image separator + uint16_t zero = 0; + fwrite(&zero, 2, 1, f); // left + fwrite(&zero, 2, 1, f); // top + uint16_t w16 = (uint16_t)width; + uint16_t h16 = (uint16_t)height; + fwrite(&w16, 2, 1, f); + fwrite(&h16, 2, 1, f); + + // local color table: flag=1, interlace=0, sort=0, reserved=0, size=(bitDepth-1) + fputc(0x80 | (bitDepth - 1), f); + + // write palette + for (int ii = 0; ii < (1 << bitDepth); ++ii) { + fputc(pal.r[ii], f); + fputc(pal.g[ii], f); + fputc(pal.b[ii], f); + } + + // compress image data + GifLzwCompress(f, image, width, height, bitDepth, kGifTransIndex); + + writer->firstFrame = false; + (void)dither; + return true; +} + +static bool GifEnd(GifWriter* writer) { + if (!writer->f) return false; + fputc(0x3b, writer->f); // GIF trailer + fclose(writer->f); + writer->f = nullptr; + return true; +} + +#endif // GIF_H diff --git a/src/gif_encoder/gif_encoder.cpp b/src/gif_encoder/gif_encoder.cpp new file mode 100644 index 0000000..e795b0b --- /dev/null +++ b/src/gif_encoder/gif_encoder.cpp @@ -0,0 +1,99 @@ +#include "gif_encoder.h" +#include "gif.h" + +#include +#include + +bool GifEncoder::encode(const QVector& frames, + const QString& filePath, + int fps, + bool enableTransparency) +{ + if (frames.isEmpty()) { + m_errorString = "No frames to encode"; + return false; + } + + // GIF delay 单位是 1/100 秒 + // fps=15 -> delay = 100/15 ≈ 7 (70ms) + uint32_t delay = (fps > 0) ? (uint32_t)(100 / fps) : 7; + if (delay < 1) delay = 1; + + // 确保目录存在 + QDir dir = QFileInfo(filePath).dir(); + if (!dir.exists()) { + dir.mkpath("."); + } + + // 以第一帧的尺寸作为 GIF 尺寸 + const QImage& first = frames.first(); + uint32_t width = (uint32_t)first.width(); + uint32_t height = (uint32_t)first.height(); + + if (width == 0 || height == 0) { + m_errorString = "Invalid frame size"; + return false; + } + + GifWriter writer; + memset(&writer, 0, sizeof(writer)); + + if (!GifBegin(&writer, filePath.toLocal8Bit().constData(), + width, height, delay, 8, false)) { + m_errorString = QString("Cannot open file: %1").arg(filePath); + return false; + } + + for (const QImage& frame : frames) { + // 确保图像尺寸一致(裁剪或缩放到第一帧尺寸) + QImage scaled = frame; + if ((uint32_t)scaled.width() != width || (uint32_t)scaled.height() != height) { + scaled = scaled.scaled((int)width, (int)height, Qt::IgnoreAspectRatio, + Qt::SmoothTransformation); + } + + QVector rgba = toRGBA(scaled, enableTransparency); + GifWriteFrame(&writer, rgba.constData(), width, height, delay, 8, false); + } + + GifEnd(&writer); + + m_errorString.clear(); + return true; +} + +QVector GifEncoder::toRGBA(const QImage& img, bool enableTransparency) const +{ + // 统一转换为 RGBA8888 格式 + QImage rgba = img.convertToFormat(QImage::Format_RGBA8888); + + int w = rgba.width(); + int h = rgba.height(); + QVector data(w * h * 4); + + for (int y = 0; y < h; ++y) { + const uint8_t* src = rgba.constScanLine(y); + uint8_t* dst = data.data() + y * w * 4; + if (enableTransparency) { + for (int x = 0; x < w; ++x) { + uint8_t a = src[x * 4 + 3]; + if (a < 128) { + // 透明像素:alpha 强制为 0,RGB 任意(gif.h 会映射到透明索引) + dst[x * 4 + 0] = 0; + dst[x * 4 + 1] = 0; + dst[x * 4 + 2] = 0; + dst[x * 4 + 3] = 0; + } else { + dst[x * 4 + 0] = src[x * 4 + 0]; // R + dst[x * 4 + 1] = src[x * 4 + 1]; // G + dst[x * 4 + 2] = src[x * 4 + 2]; // B + dst[x * 4 + 3] = 255; + } + } + } else { + memcpy(dst, src + 0, (size_t)(w * 4)); + } + } + + return data; +} diff --git a/src/gif_encoder/gif_encoder.h b/src/gif_encoder/gif_encoder.h new file mode 100644 index 0000000..f07c1c2 --- /dev/null +++ b/src/gif_encoder/gif_encoder.h @@ -0,0 +1,38 @@ +#ifndef GIF_ENCODER_H +#define GIF_ENCODER_H + +#include +#include +#include + +// GifEncoder +// ---------- +// 将 QImage 帧序列编码为 GIF 文件。 +// - 支持透明通道(alpha < 128 的像素映射到 GIF 透明索引) +// - 编码使用 gif.h 单头文件库(6x6x6 色彩量化 + LZW 压缩) +// - encode() 是同步操作,建议在子线程(QtConcurrent)调用 + +class GifEncoder +{ +public: + GifEncoder() = default; + + // fps: 帧率(如 15) + // enableTransparency: 是否将半透明像素处理为透明 + // 返回 true 表示成功,errorString() 返回错误信息 + bool encode(const QVector& frames, + const QString& filePath, + int fps, + bool enableTransparency = true); + + QString errorString() const { return m_errorString; } + +private: + // 将 QImage 转为 gif.h 需要的 RGBA8888 原始字节数组 + // 如果 enableTransparency=true,alpha<128 的像素 alpha 强制为 0 + QVector toRGBA(const QImage& img, bool enableTransparency) const; + + QString m_errorString; +}; + +#endif // GIF_ENCODER_H diff --git a/src/gif_encoder/gif_frame_grabber.cpp b/src/gif_encoder/gif_frame_grabber.cpp new file mode 100644 index 0000000..199d96a --- /dev/null +++ b/src/gif_encoder/gif_frame_grabber.cpp @@ -0,0 +1,63 @@ +#include "gif_frame_grabber.h" + +#include +#include +#include + +GifFrameGrabber::GifFrameGrabber(QObject* parent) + : QObject(parent) +{ + connect(&m_timer, &QTimer::timeout, this, &GifFrameGrabber::onTimerTick); +} + +void GifFrameGrabber::start(const QRect& captureRect, int fps) +{ + if (captureRect.isEmpty() || fps <= 0) return; + + m_rect = captureRect; + int intervalMs = 1000 / fps; + m_timer.start(intervalMs); +} + +void GifFrameGrabber::stop() +{ + m_timer.stop(); +} + +void GifFrameGrabber::updateRect(const QRect& captureRect) +{ + if (!captureRect.isEmpty()) + m_rect = captureRect; +} + +void GifFrameGrabber::onTimerTick() +{ + QScreen* screen = QGuiApplication::primaryScreen(); + if (!screen) return; + + // 抓取全屏后裁剪到选区(与 DesktopSnapshot 一致,HiDPI 安全) + QPixmap fullScreen = screen->grabWindow(0); + if (fullScreen.isNull()) return; + + // 将逻辑坐标转为物理像素坐标(处理 HiDPI) + qreal dpr = screen->devicePixelRatio(); + QRect physicalRect( + qRound(m_rect.x() * dpr), + qRound(m_rect.y() * dpr), + qRound(m_rect.width() * dpr), + qRound(m_rect.height() * dpr) + ); + + QImage fullImage = fullScreen.toImage(); + QImage frame = fullImage.copy(physicalRect); + + if (!frame.isNull()) { + // 缩回逻辑分辨率,确保所有帧尺寸一致 + if (dpr != 1.0) { + frame = frame.scaled(m_rect.width(), m_rect.height(), + Qt::IgnoreAspectRatio, + Qt::SmoothTransformation); + } + emit frameCaptured(frame); + } +} diff --git a/src/gif_encoder/gif_frame_grabber.h b/src/gif_encoder/gif_frame_grabber.h new file mode 100644 index 0000000..13d1a81 --- /dev/null +++ b/src/gif_encoder/gif_frame_grabber.h @@ -0,0 +1,43 @@ +#ifndef GIF_FRAME_GRABBER_H +#define GIF_FRAME_GRABBER_H + +#include +#include +#include +#include + +// GifFrameGrabber +// --------------- +// 使用 QTimer 按固定帧率从屏幕抓取指定区域的帧。 +// 每帧抓取后发出 frameCaptured 信号。 +// 线程:运行在主线程(QScreen::grabWindow 必须在主线程调用)。 + +class GifFrameGrabber : public QObject +{ + Q_OBJECT +public: + explicit GifFrameGrabber(QObject* parent = nullptr); + + // 开始抓帧:captureRect 为逻辑坐标选区,fps 为帧率 + void start(const QRect& captureRect, int fps); + + // 录制中实时更新抓取区域(下一帧起生效,线程安全) + void updateRect(const QRect& captureRect); + + // 停止抓帧 + void stop(); + + bool isRunning() const { return m_timer.isActive(); } + +signals: + void frameCaptured(const QImage& frame); + +private slots: + void onTimerTick(); + +private: + QTimer m_timer; + QRect m_rect; +}; + +#endif // GIF_FRAME_GRABBER_H diff --git a/src/language_manager.cpp b/src/language_manager.cpp new file mode 100644 index 0000000..40eef6d --- /dev/null +++ b/src/language_manager.cpp @@ -0,0 +1,52 @@ +#include "language_manager.h" + +#include +#include +#include + +LanguageManager::LanguageManager(AppSettings &settings, QObject *parent) + : QObject(parent), m_settings(settings) +{ + // 启动时加载翻译 + loadTranslator(m_settings.language()); +} + +QString LanguageManager::language() const +{ + return m_settings.language(); +} + +void LanguageManager::setLanguage(const QString &lang) +{ + if (m_settings.language() == lang) return; + m_settings.setLanguage(lang); + emit languageChanged(lang); + restartApp(); +} + +void LanguageManager::restartApp() +{ + // 用当前可执行文件路径和参数重新启动,然后退出当前进程 + QProcess::startDetached(QCoreApplication::applicationFilePath(), + QCoreApplication::arguments()); + QCoreApplication::quit(); +} + +void LanguageManager::loadTranslator(const QString &lang) +{ + // 中文是源语言:不加载翻译文件,qsTr() 直接返回中文源字符串 + if (lang == "zh_CN") return; + + m_translator = new QTranslator(this); + QString qmFile = QString(":/i18n/app_%1.qm").arg(lang); + if (!m_translator->load(qmFile)) { + qmFile = QString("app_%1.qm").arg(lang); + if (!m_translator->load(qmFile, QCoreApplication::applicationDirPath())) { + qWarning() << "[LanguageManager] 翻译文件未找到:" << qmFile; + delete m_translator; + m_translator = nullptr; + return; + } + } + QCoreApplication::installTranslator(m_translator); +} diff --git a/src/language_manager.h b/src/language_manager.h new file mode 100644 index 0000000..5ee9482 --- /dev/null +++ b/src/language_manager.h @@ -0,0 +1,38 @@ +#ifndef LANGUAGE_MANAGER_H +#define LANGUAGE_MANAGER_H + +#include +#include +#include "model/app_settings.h" + +// LanguageManager +// ─────────────── +// 启动时根据 AppSettings 中保存的语言加载对应翻译文件。 +// setLanguage() 只持久化设置,重启后生效。 +// 暴露给 QML 为 O_LanguageManager。 + +class LanguageManager : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString language READ language NOTIFY languageChanged) + +public: + explicit LanguageManager(AppSettings &settings, QObject *parent = nullptr); + + QString language() const; + + // 保存新语言并立即重启应用 + Q_INVOKABLE void setLanguage(const QString &lang); + +signals: + void languageChanged(const QString &lang); + +private: + void loadTranslator(const QString &lang); + static void restartApp(); + + AppSettings &m_settings; + QTranslator *m_translator = nullptr; +}; + +#endif // LANGUAGE_MANAGER_H diff --git a/src/magnifier_provider.cpp b/src/magnifier_provider.cpp new file mode 100644 index 0000000..eb26f98 --- /dev/null +++ b/src/magnifier_provider.cpp @@ -0,0 +1,93 @@ +#include "magnifier_provider.h" +#include + +MagnifierProvider::MagnifierProvider(ScreenshotViewModel *viewModel) + : QQuickImageProvider(QQuickImageProvider::Image) + , m_viewModel(viewModel) +{ +} + +// requestImage +// ------------ +// id 格式:","(由 QML 传入的逻辑像素鼠标坐标) +QImage MagnifierProvider::requestImage(const QString &id, QSize *size, const QSize & /*requestedSize*/) +{ + const QString cleanId = id.section('?', 0, 0); + + // 解析坐标 + const QStringList parts = cleanId.split(','); + if (parts.size() < 2) { + QImage blank(kOutputSize, kOutputSize, QImage::Format_ARGB32_Premultiplied); + blank.fill(Qt::black); + if (size) *size = blank.size(); + return blank; + } + bool okX = false; + bool okY = false; + const int cx = parts[0].toInt(&okX); + const int cy = parts[1].toInt(&okY); + if (!okX || !okY) { + QImage blank(kOutputSize, kOutputSize, QImage::Format_ARGB32_Premultiplied); + blank.fill(Qt::black); + if (size) *size = blank.size(); + return blank; + } + + // 从桌面快照中取一份线程安全的副本 + const QImage snapshot = m_viewModel->desktopSnapshot(); + if (snapshot.isNull()) { + QImage blank(kOutputSize, kOutputSize, QImage::Format_ARGB32_Premultiplied); + blank.fill(Qt::black); + if (size) *size = blank.size(); + return blank; + } + + // 逻辑坐标 → 快照像素坐标(按虚拟桌面逻辑尺寸与快照物理像素比例换算,支持多屏) + const QRect vg = m_viewModel->virtualGeometry(); + const qreal scaleX = (vg.width() > 0) ? (qreal(snapshot.width()) / qreal(vg.width())) : 1.0; + const qreal scaleY = (vg.height() > 0) ? (qreal(snapshot.height()) / qreal(vg.height())) : 1.0; + const int pcx = qRound((cx - vg.x()) * scaleX); + const int pcy = qRound((cy - vg.y()) * scaleY); + + // 采样半径按 X 轴比例缩放;Y 轴保持一致比例,保证中心点准确 + const int srcRadiusX = qMax(1, qRound(kSrcRadius * scaleX)); + const int srcRadiusY = qMax(1, qRound(kSrcRadius * scaleY)); + const int srcDiamW = srcRadiusX * 2; + const int srcDiamH = srcRadiusY * 2; + + // 计算裁剪矩形(以物理像素光标为中心) + QRect srcRect(pcx - srcRadiusX, pcy - srcRadiusY, srcDiamW, srcDiamH); + + // 处理边界:当光标靠近屏幕边缘时,用黑色填充越界区域 + QImage region(srcDiamW, srcDiamH, QImage::Format_ARGB32_Premultiplied); + region.fill(Qt::black); + { + QPainter rp(®ion); + QRect bounded = srcRect.intersected(snapshot.rect()); + if (!bounded.isEmpty()) { + int destX = bounded.x() - srcRect.x(); + int destY = bounded.y() - srcRect.y(); + rp.drawImage(destX, destY, snapshot, bounded.x(), bounded.y(), + bounded.width(), bounded.height()); + } + rp.end(); + } + + // 放大到 kOutputSize×kOutputSize + QImage zoomed = region.scaled(kOutputSize, kOutputSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + + // 绘制红色十字准线(1px 宽) + { + QPainter p(&zoomed); + p.setRenderHint(QPainter::Antialiasing, false); + QPen pen(QColor(255, 0, 0, 220), 1); + p.setPen(pen); + const int half = kOutputSize / 2; + p.drawLine(half, 0, half, kOutputSize - 1); // 竖线 + p.drawLine(0, half, kOutputSize - 1, half); // 横线 + p.end(); + } + + if (size) *size = zoomed.size(); + return zoomed; +} diff --git a/src/magnifier_provider.h b/src/magnifier_provider.h new file mode 100644 index 0000000..46279c1 --- /dev/null +++ b/src/magnifier_provider.h @@ -0,0 +1,36 @@ +#ifndef MAGNIFIER_PROVIDER_H +#define MAGNIFIER_PROVIDER_H + +#include +#include "viewmodel/screenshot_view_model.h" + +// MagnifierProvider +// ----------------- +// QQuickImageProvider 子类,供 QML Image 通过 +// source: "image://magnifier/," +// 获取以 (x,y) 为中心的放大镜图像。 +// +// 图像规格: +// 输出尺寸:110×110(QML 侧再用 width/height 撑到 130×130 预留边距) +// 采样区域:以 (x,y) 为中心、半径 40 像素(共 80×80)从桌面快照裁剪 +// 放大倍率:~1.375x(110/80) +// 十字准线:红色,绘制在 requestImage() 内部 +// +// 线程安全:requestImage() 可能在非主线程调用,只对 QImage 做只读 copy()。 + +class MagnifierProvider : public QQuickImageProvider +{ +public: + explicit MagnifierProvider(ScreenshotViewModel *viewModel); + + // QQuickImageProvider interface + QImage requestImage(const QString &id, QSize *size, const QSize &requestedSize) override; + +private: + ScreenshotViewModel *m_viewModel; + + static const int kSrcRadius = 20; // 采样半径(源区域 40×40) + static const int kOutputSize = 110; // 输出图像尺寸 +}; + +#endif // MAGNIFIER_PROVIDER_H diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..d3797e7 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,83 @@ +#include +#include +#include + +#include "tray_icon_helper.h" +#include "paint_board/paint_board.h" +#include "shortcut_key/globalshortcut.h" +#include "magnifier_provider.h" +#include "sticky_image_store.h" +#include "sticky_image_provider_proxy.h" +#include "language_manager.h" + +// Model +#include "model/desktop_snapshot.h" +#include "model/app_settings.h" + +// ViewModel +#include "viewmodel/screenshot_view_model.h" +#include "viewmodel/sticky_view_model.h" +#include "viewmodel/ai_view_model.h" +#include "viewmodel/storage_view_model.h" +#include "viewmodel/gif_record_view_model.h" +#include "viewmodel/ocr_view_model.h" + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + QCoreApplication::setOrganizationName("TZshot"); + QCoreApplication::setOrganizationDomain("tzshot.local"); + QCoreApplication::setApplicationName("TZshot"); + app.setQuitOnLastWindowClosed(false); + + + // ── Model 层 ────────────────────────────────────────── + DesktopSnapshot desktopSnapshot; + AppSettings appSettings; + StickyImageStore stickyImageStore; + + // ── ViewModel 层 ────────────────────────────────────── + ScreenshotViewModel screenshotViewModel(desktopSnapshot, stickyImageStore); + StickyViewModel stickyViewModel(stickyImageStore); + AIViewModel aiViewModel(appSettings, stickyImageStore); + StorageViewModel storageViewModel(appSettings); + LanguageManager languageManager(appSettings); + GifRecordViewModel gifRecordViewModel(appSettings); + OcrViewModel ocrViewModel; + + + // ── 其他服务(不属于 MVVM 任何层)─────────────────── + GlobalShortcut globalShortcut; + + QQmlApplicationEngine engine; + + + // ── Qt 基础设施注册 ─────────────────────────────────── + engine.addImportPath("qrc:/qml"); + qmlRegisterType("CustomComponents", 1, 0, "PaintBoard"); + qmlRegisterType("com.Tz.tray", 1, 0, "TrayIconHelper"); + + // MagnifierProvider 持有 ScreenshotViewModel 引用访问快照,engine 接管生命周期 + engine.addImageProvider("magnifier", new MagnifierProvider(&screenshotViewModel)); + // StickyImageProviderProxy 转发到 StickyImageStore,engine 接管生命周期 + engine.addImageProvider("sticky", new StickyImageProviderProxy(stickyImageStore)); + + // ── 暴露给 QML 的 ViewModel ─────────────────────────── + engine.rootContext()->setContextProperty("O_ScreenCapture", &screenshotViewModel); + engine.rootContext()->setContextProperty("O_StickyViewModel", &stickyViewModel); + engine.rootContext()->setContextProperty("O_AIViewModel", &aiViewModel); + engine.rootContext()->setContextProperty("O_ImageSaver", &storageViewModel); + engine.rootContext()->setContextProperty("O_GlobalShortcut", &globalShortcut); + engine.rootContext()->setContextProperty("O_LanguageManager", &languageManager); + engine.rootContext()->setContextProperty("O_GifRecordVM", &gifRecordViewModel); + engine.rootContext()->setContextProperty("O_OcrVM", &ocrViewModel); + + const QUrl url(QStringLiteral("qrc:/qml/main.qml")); + QObject::connect(&engine, &QQmlApplicationEngine::objectCreated, + &app, [url](QObject *obj, const QUrl &objUrl) { + if (!obj && url == objUrl) + QCoreApplication::exit(-1); + }, Qt::QueuedConnection); + engine.load(url); + return app.exec(); +} diff --git a/src/model/app_settings.cpp b/src/model/app_settings.cpp new file mode 100644 index 0000000..9552c3f --- /dev/null +++ b/src/model/app_settings.cpp @@ -0,0 +1,102 @@ +#include "app_settings.h" + +#include +#include +#include +#include +#include + +AppSettings::AppSettings() +{ + QSettings s; + + m_apiKey = s.value("AI/apiKey", "").toString(); + m_selectedModel = s.value("AI/selectedModel", 0).toInt(); + // 语言:若用户从未手动设置过,则根据系统语言选择默认值 + if (s.contains("App/language")) { + m_language = s.value("App/language").toString(); + } else { + // QLocale::system().name() 返回形如 "zh_CN"、"en_US" 等 + QString sysLang = QLocale::system().name(); // e.g. "zh_CN", "en_US" + if (sysLang.startsWith("zh")) { + m_language = "zh_CN"; + } else { + m_language = "en"; + } + } + + QString savedPath = s.value("ImageSaver/savePath").toString(); + if (!savedPath.isEmpty() && QDir(savedPath).exists()) { + m_savePath = savedPath; + } else { + QString picPath = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation); + m_savePath = QDir(picPath).filePath("TZshot"); + QDir dir; + if (!dir.exists(m_savePath)) { + if (!dir.mkpath(m_savePath)) { + qWarning() << "[AppSettings] 创建默认保存路径失败,回退到图片根目录"; + m_savePath = picPath; + } + } + } + qInfo() << "[AppSettings] 保存路径:" << m_savePath; +} + +// ── AI 配置 ─────────────────────────────────────────────── + +QString AppSettings::apiKey() const +{ + return m_apiKey; +} + +void AppSettings::setApiKey(const QString &key) +{ + if (m_apiKey == key) return; + m_apiKey = key; + QSettings().setValue("AI/apiKey", m_apiKey); +} + +int AppSettings::selectedModel() const +{ + return m_selectedModel; +} + +void AppSettings::setSelectedModel(int index) +{ + if (m_selectedModel == index) return; + m_selectedModel = index; + QSettings().setValue("AI/selectedModel", m_selectedModel); +} + +// ── 语言配置 ────────────────────────────────────────────── + +QString AppSettings::language() const +{ + return m_language; +} + +void AppSettings::setLanguage(const QString &lang) +{ + if (m_language == lang) return; + m_language = lang; + QSettings().setValue("App/language", m_language); +} + +// ── 保存路径 ────────────────────────────────────────────── + +QString AppSettings::savePath() const +{ + return m_savePath; +} + +void AppSettings::setSavePath(const QString &path) +{ + if (m_savePath == path || path.isEmpty()) return; + m_savePath = path; + + QDir dir; + if (!dir.exists(m_savePath)) + dir.mkpath(m_savePath); + + QSettings().setValue("ImageSaver/savePath", m_savePath); +} diff --git a/src/model/app_settings.h b/src/model/app_settings.h new file mode 100644 index 0000000..461e47a --- /dev/null +++ b/src/model/app_settings.h @@ -0,0 +1,40 @@ +#ifndef APP_SETTINGS_H +#define APP_SETTINGS_H + +#include +#include + +// AppSettings — Model +// ------------------- +// 负责应用配置数据的读写(QSettings 持久化)。 +// 不继承 QObject,不依赖 QML,纯数据存取。 +// 所有 setter 在值变更时自动写入 QSettings。 + +class AppSettings +{ +public: + AppSettings(); + + // AI 配置 + QString apiKey() const; + void setApiKey(const QString &key); + + int selectedModel() const; + void setSelectedModel(int index); + + // 语言配置("zh_CN" 或 "en") + QString language() const; + void setLanguage(const QString &lang); + + // 截图保存路径 + QString savePath() const; + void setSavePath(const QString &path); + +private: + QString m_apiKey; + int m_selectedModel = 0; + QString m_language = "zh_CN"; + QString m_savePath; +}; + +#endif // APP_SETTINGS_H diff --git a/src/model/desktop_snapshot.cpp b/src/model/desktop_snapshot.cpp new file mode 100644 index 0000000..f02563c --- /dev/null +++ b/src/model/desktop_snapshot.cpp @@ -0,0 +1,250 @@ +#include "desktop_snapshot.h" + +#include +#include +#include +#include +#include +#include +#include + +/* ── grab / release / pixelColor / windowAtPoint ──────── + * 这几个函数与平台无关,统一实现 + * ────────────────────────────────────────────────────── */ + +void DesktopSnapshot::grab() +{ + const QList screens = QGuiApplication::screens(); + if (screens.isEmpty()) { + qWarning() << "[DesktopSnapshot] 无法获取屏幕列表"; + return; + } + + // 等待合成器/DWM 将截图窗口从帧缓冲移除 + QEventLoop loop; + QTimer::singleShot(50, &loop, &QEventLoop::quit); + loop.exec(); + + // 用 QScreen::virtualGeometry() 获取每块屏幕在虚拟桌面坐标系中的位置。 + // virtualGeometry() 使用主屏 DPR 作为基准(与 QML Screen.virtualX/width 对齐), + // 避免混合 DPI 场景下各屏 geometry() 坐标系不一致的问题。 + m_virtualGeometry = QRect(); + for (QScreen *s : screens) + m_virtualGeometry = m_virtualGeometry.united(s->virtualGeometry()); + + qDebug() << "[DesktopSnapshot] 检测到屏幕数:" << screens.size() + << "虚拟桌面逻辑矩形:" << m_virtualGeometry; + + // 创建与虚拟桌面等大的物理像素画布(以主屏 DPR 为基准) + // 主屏 DPR 即虚拟桌面坐标系的缩放基准,与 QML 侧一致 + const qreal baseDpr = QGuiApplication::primaryScreen() + ? QGuiApplication::primaryScreen()->devicePixelRatio() + : 1.0; + + const int canvasW = qRound(m_virtualGeometry.width() * baseDpr); + const int canvasH = qRound(m_virtualGeometry.height() * baseDpr); + m_snapshot = QImage(canvasW, canvasH, QImage::Format_ARGB32_Premultiplied); + m_snapshot.fill(Qt::black); + + // 逐屏抓图并合并到画布 + QPainter painter(&m_snapshot); + for (QScreen *s : screens) { + QImage img = s->grabWindow(0).toImage(); + + // 该屏在虚拟桌面坐标系下的位置(已对齐主屏 DPR) + const int logOffX = s->virtualGeometry().x() - m_virtualGeometry.x(); + const int logOffY = s->virtualGeometry().y() - m_virtualGeometry.y(); + + // 转换为物理像素偏移 + const int physOffX = qRound(logOffX * baseDpr); + const int physOffY = qRound(logOffY * baseDpr); + + // 将该屏快照缩放到 baseDpr 下的等效物理像素尺寸 + const int physW = qRound(s->virtualGeometry().width() * baseDpr); + const int physH = qRound(s->virtualGeometry().height() * baseDpr); + if (img.width() != physW || img.height() != physH) + img = img.scaled(physW, physH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + + painter.drawImage(physOffX, physOffY, img); + } + painter.end(); + + qDebug() << "[DesktopSnapshot] 快照已缓存,尺寸:" + << m_snapshot.width() << "x" << m_snapshot.height(); + + buildWindowList(); +} + +void DesktopSnapshot::release() +{ + m_snapshot = QImage(); + m_virtualGeometry = QRect(); + m_windowRects.clear(); +} + +QColor DesktopSnapshot::pixelColor(int logicalX, int logicalY) const +{ + if (m_snapshot.isNull() || m_virtualGeometry.isEmpty()) return QColor(); + + // 逻辑坐标相对于虚拟桌面原点的偏移 + const int relX = logicalX - m_virtualGeometry.x(); + const int relY = logicalY - m_virtualGeometry.y(); + + // 按快照实际尺寸与虚拟桌面逻辑尺寸比例换算(统一 DPR) + const qreal scaleX = m_virtualGeometry.width() > 0 + ? qreal(m_snapshot.width()) / qreal(m_virtualGeometry.width()) : 1.0; + const qreal scaleY = m_virtualGeometry.height() > 0 + ? qreal(m_snapshot.height()) / qreal(m_virtualGeometry.height()) : 1.0; + + const int px = qRound(relX * scaleX); + const int py = qRound(relY * scaleY); + + if (px < 0 || py < 0 || px >= m_snapshot.width() || py >= m_snapshot.height()) + return QColor(); + + return QColor(m_snapshot.pixel(px, py)); +} + +QRect DesktopSnapshot::windowAtPoint(int x, int y) const +{ + for (const QRect &r : m_windowRects) { + if (r.contains(x, y)) + return r; + } + return QRect(); +} + +/* ══════════════════════════════════════════════════════════ + * buildWindowList — 平台专属实现 + * ══════════════════════════════════════════════════════════ */ + +/* ──────────────────────────────────────────────────────── + * Linux / Unix:通过 _NET_CLIENT_LIST_STACKING 枚举窗口 + * ──────────────────────────────────────────────────────── */ +#ifdef Q_OS_UNIX + +#include +#include + +void DesktopSnapshot::buildWindowList() +{ + m_windowRects.clear(); + + Display *dpy = XOpenDisplay(nullptr); + if (!dpy) return; + + Window root = DefaultRootWindow(dpy); + + Atom netClientListStacking = XInternAtom(dpy, "_NET_CLIENT_LIST_STACKING", False); + Atom actualType; + int actualFormat; + unsigned long nitems = 0, bytesAfter = 0; + unsigned char *data = nullptr; + + int ret = XGetWindowProperty(dpy, root, netClientListStacking, + 0, 65536, False, XA_WINDOW, + &actualType, &actualFormat, + &nitems, &bytesAfter, &data); + + if (ret != Success || !data) { + Atom netClientList = XInternAtom(dpy, "_NET_CLIENT_LIST", False); + ret = XGetWindowProperty(dpy, root, netClientList, + 0, 65536, False, XA_WINDOW, + &actualType, &actualFormat, + &nitems, &bytesAfter, &data); + if (ret != Success || !data) { + XCloseDisplay(dpy); + return; + } + } + + Window *wins = reinterpret_cast(data); + + for (long i = static_cast(nitems) - 1; i >= 0; --i) { + Window w = wins[i]; + + XWindowAttributes attr; + if (!XGetWindowAttributes(dpy, w, &attr)) continue; + if (attr.map_state != IsViewable) continue; + if (attr.width < 10) continue; + if (attr.height < 10) continue; + + int screenX = 0, screenY = 0; + Window child; + XTranslateCoordinates(dpy, w, root, 0, 0, &screenX, &screenY, &child); + + QRect r(screenX, screenY, attr.width, attr.height); + r = r.intersected(m_virtualGeometry); + if (r.isEmpty()) continue; + + m_windowRects.append(r); + } + + XFree(data); + XCloseDisplay(dpy); + + qDebug() << "[DesktopSnapshot] 枚举到顶层窗口数:" << m_windowRects.size(); +} + +#endif /* Q_OS_UNIX */ + +/* ──────────────────────────────────────────────────────── + * Windows:通过 EnumWindows 枚举可见顶层窗口 + * ──────────────────────────────────────────────────────── */ +#ifdef Q_OS_WIN + +#define WIN32_LEAN_AND_MEAN +#include + +/* EnumWindows 回调:收集所有可见且有标题栏的顶层窗口矩形 */ +struct EnumWindowsParam { + QVector *rects; + QRect snapshotRect; /* 逻辑像素,用于裁剪 */ + qreal dpr; /* 主屏 DPR,用于物理像素→逻辑像素转换 */ +}; + +static BOOL CALLBACK enumWindowsProc(HWND hwnd, LPARAM lParam) +{ + EnumWindowsParam *param = reinterpret_cast(lParam); + + if (!IsWindowVisible(hwnd)) return TRUE; + if (IsIconic(hwnd)) return TRUE; /* 最小化,跳过 */ + + /* 跳过没有 WS_CAPTION 的工具窗口、桌面壳层等 */ + LONG style = GetWindowLong(hwnd, GWL_STYLE); + if (!(style & WS_CAPTION)) return TRUE; + + RECT rc; + if (!GetWindowRect(hwnd, &rc)) return TRUE; + + int w = rc.right - rc.left; + int h = rc.bottom - rc.top; + if (w < 10 || h < 10) return TRUE; + + /* GetWindowRect 返回物理像素坐标,转换为逻辑像素(对齐 Qt 坐标系)*/ + QRect r(qRound(rc.left / param->dpr), + qRound(rc.top / param->dpr), + qRound(w / param->dpr), + qRound(h / param->dpr)); + r = r.intersected(param->snapshotRect); + if (!r.isEmpty()) + param->rects->append(r); /* EnumWindows 本身按 Z 序(顶->底)枚举,append 才能保持从顶到底 */ + + return TRUE; +} + +void DesktopSnapshot::buildWindowList() +{ + m_windowRects.clear(); + + const qreal dpr = QGuiApplication::primaryScreen() + ? QGuiApplication::primaryScreen()->devicePixelRatio() + : 1.0; + + EnumWindowsParam param { &m_windowRects, m_virtualGeometry, dpr }; + EnumWindows(enumWindowsProc, reinterpret_cast(¶m)); + + qDebug() << "[DesktopSnapshot] 枚举到顶层窗口数:" << m_windowRects.size(); +} + +#endif /* Q_OS_WIN */ diff --git a/src/model/desktop_snapshot.h b/src/model/desktop_snapshot.h new file mode 100644 index 0000000..ecca6ea --- /dev/null +++ b/src/model/desktop_snapshot.h @@ -0,0 +1,46 @@ +#ifndef DESKTOP_SNAPSHOT_H +#define DESKTOP_SNAPSHOT_H + +#include +#include +#include +#include + +// DesktopSnapshot — Model +// 负责全屏快照的采集与存储,以及顶层窗口列表的枚举。 +// 支持多屏:将所有屏幕的快照拼合为一张以虚拟桌面坐标系为基准的大图。 +// 不依赖 QML,不继承 QObject,纯数据/系统调用。 + +class DesktopSnapshot +{ +public: + DesktopSnapshot() = default; + + // 抓取所有屏幕快照并拼合,枚举顶层窗口列表(含 50ms 延时等待合成器) + void grab(); + // 释放快照和窗口列表 + void release(); + + bool isNull() const { return m_snapshot.isNull(); } + + // 拼合后的虚拟桌面快照(物理像素坐标系) + const QImage& image() const { return m_snapshot; } + + // 虚拟桌面的逻辑坐标矩形(所有屏幕的 geometry 联合,逻辑像素) + QRect virtualGeometry() const { return m_virtualGeometry; } + + // 将逻辑坐标 (x,y) 转换为快照中的颜色(支持多屏 DPI 混合) + QColor pixelColor(int logicalX, int logicalY) const; + + // 返回包含逻辑坐标点 (x,y) 的最上层窗口矩形(逻辑坐标) + QRect windowAtPoint(int x, int y) const; + +private: + void buildWindowList(); + + QImage m_snapshot; + QRect m_virtualGeometry; // 虚拟桌面逻辑矩形(所有屏幕 geometry 的联合) + QVector m_windowRects; // 按 Z 轴从顶到底排列(逻辑坐标) +}; + +#endif // DESKTOP_SNAPSHOT_H diff --git a/src/ocr/ocr_engine.cpp b/src/ocr/ocr_engine.cpp new file mode 100644 index 0000000..4122e65 --- /dev/null +++ b/src/ocr/ocr_engine.cpp @@ -0,0 +1,307 @@ +#include "ocr_engine.h" + +#ifdef ENABLE_OCR +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +bool hasRequiredLanguages(const QString &tessdataDir) +{ + return QFileInfo::exists(tessdataDir + "/eng.traineddata") + && QFileInfo::exists(tessdataDir + "/chi_sim.traineddata"); +} + +QStringList resolveTessdataDirs() +{ + const QString appDir = QCoreApplication::applicationDirPath(); + QStringList candidates = { +#ifdef OCR_TESSDATA_DIR + QStringLiteral(OCR_TESSDATA_DIR), +#endif + appDir + "/thirdpart/ocr-win/share/tessdata", + appDir + "/../thirdpart/ocr-win/share/tessdata", + appDir + "/../../thirdpart/ocr-win/share/tessdata", + appDir + "/thirdpart/ocr/share/tessdata", + appDir + "/../thirdpart/ocr/share/tessdata", + appDir + "/../../thirdpart/ocr/share/tessdata", + QStringLiteral("/usr/share/tesseract-ocr/5/tessdata"), + QStringLiteral("/usr/share/tesseract-ocr/4.00/tessdata") + }; + + const QByteArray envPrefix = qgetenv("TESSDATA_PREFIX"); + if (!envPrefix.isEmpty()) { + const QString envPath = QString::fromUtf8(envPrefix); + if (QFileInfo(envPath).fileName() == QStringLiteral("tessdata")) { + candidates.prepend(envPath); + } else { + candidates.prepend(envPath + "/tessdata"); + } + } + + QStringList validDirs; + for (const QString &tessdataDir : candidates) { + const QString normalized = QDir(tessdataDir).absolutePath(); + if (!validDirs.contains(normalized) && hasRequiredLanguages(normalized)) { + validDirs << normalized; + } + } + return validDirs; +} + +QString resolveTesseractProgram() +{ + const QString appDir = QCoreApplication::applicationDirPath(); + + const QByteArray envProgram = qgetenv("TESSERACT_PATH"); + if (!envProgram.isEmpty()) { + const QString envPath = QDir::fromNativeSeparators(QString::fromUtf8(envProgram)); + if (QFileInfo::exists(envPath)) { + return QDir(envPath).absolutePath(); + } + } + + QStringList candidatePaths = { + appDir + "/thirdpart/ocr-win/bin", + appDir + "/../thirdpart/ocr-win/bin", + appDir + "/../../thirdpart/ocr-win/bin", + appDir + "/thirdpart/ocr/bin", + appDir + "/../thirdpart/ocr/bin", + appDir + "/../../thirdpart/ocr/bin" + }; + +#ifdef Q_OS_WIN + candidatePaths << QStringLiteral("C:/Program Files/Tesseract-OCR") + << QStringLiteral("C:/Program Files (x86)/Tesseract-OCR"); + + const QString localAppData = QDir::fromNativeSeparators( + QString::fromUtf8(qgetenv("LOCALAPPDATA")) + ); + if (!localAppData.isEmpty()) { + candidatePaths << localAppData + "/Programs/Tesseract-OCR"; + } +#endif + + const QStringList executableNames = { +#ifdef Q_OS_WIN + QStringLiteral("tesseract.exe"), +#endif + QStringLiteral("tesseract") + }; + + for (const QString &name : executableNames) { + const QString resolved = QStandardPaths::findExecutable(name, candidatePaths); + if (!resolved.isEmpty()) { + return QDir::fromNativeSeparators(resolved); + } + + const QString fromPath = QStandardPaths::findExecutable(name); + if (!fromPath.isEmpty()) { + return QDir::fromNativeSeparators(fromPath); + } + } + + return {}; +} +} // namespace + +OcrEngine::OcrEngine() +{ +#ifdef ENABLE_OCR + m_api = new tesseract::TessBaseAPI(); + +#ifdef Q_OS_WIN + m_api->SetVariable("debug_file", "NUL"); +#else + m_api->SetVariable("debug_file", "/dev/null"); +#endif + + const QStringList tessdataDirs = resolveTessdataDirs(); + bool initOk = false; + QString selectedTessdataDir; + + for (const QString &tessdataDir : tessdataDirs) { + m_api->End(); + qputenv("TESSDATA_PREFIX", tessdataDir.toUtf8()); + + if (m_api->Init(nullptr, "chi_sim+eng") == 0) { + initOk = true; + selectedTessdataDir = tessdataDir; + break; + } + } + + if (!initOk) { + if (tessdataDirs.isEmpty()) { + m_lastError = QStringLiteral("Tesseract Init 失败(chi_sim+eng):未找到可用 tessdata"); + } else { + m_lastError = QStringLiteral("Tesseract Init 失败(chi_sim+eng),候选目录=%1") + .arg(tessdataDirs.join(QStringLiteral("; "))); + } + qWarning() << "[OcrEngine]" << m_lastError; + delete m_api; + m_api = nullptr; + return; + } + + qInfo() << "[OcrEngine] Tesseract Init success, tessdata=" << selectedTessdataDir; + m_api->SetPageSegMode(tesseract::PSM_AUTO); + m_lastError.clear(); + m_ready = true; +#else + m_tesseractProgram = resolveTesseractProgram(); + if (m_tesseractProgram.isEmpty()) { + m_lastError = QStringLiteral("OCR 未启用:未找到 tesseract 可执行文件。请安装 Tesseract,或设置 TESSERACT_PATH 指向可执行文件"); + m_ready = false; + return; + } + + const QStringList tessdataDirs = resolveTessdataDirs(); + if (!tessdataDirs.isEmpty()) { + m_tessdataDir = tessdataDirs.first(); + } + + qInfo() << "[OcrEngine] CLI fallback enabled, tesseract=" << m_tesseractProgram + << "tessdata=" << (m_tessdataDir.isEmpty() ? QStringLiteral("") : m_tessdataDir); + m_lastError.clear(); + m_ready = true; +#endif +} + +OcrEngine::~OcrEngine() +{ +#ifdef ENABLE_OCR + if (m_api) { + m_api->End(); + delete m_api; + m_api = nullptr; + } +#endif +} + +QString OcrEngine::recognize(const QImage &image) +{ +#ifdef ENABLE_OCR + if (!m_ready || !m_api) { + m_lastError = QStringLiteral("OCR 引擎未就绪"); + return {}; + } + + if (image.isNull()) { + m_lastError = QStringLiteral("输入图像为空"); + return {}; + } + + QImage rgb = image.convertToFormat(QImage::Format_RGB888); + if (rgb.width() < 2 || rgb.height() < 2) { + m_lastError = QStringLiteral("输入图像尺寸过小"); + return {}; + } + + // 为图像加白边,降低文本贴边时 Leptonica 产生无效 box 警告的概率。 + constexpr int kPad = 10; + QImage padded(rgb.width() + kPad * 2, rgb.height() + kPad * 2, QImage::Format_RGB888); + padded.fill(Qt::white); + { + QPainter painter(&padded); + painter.drawImage(kPad, kPad, rgb); + } + + m_api->SetSourceResolution(300); + m_api->SetImage( + padded.constBits(), + padded.width(), + padded.height(), + 3, + padded.bytesPerLine()); + + char *rawText = m_api->GetUTF8Text(); + if (!rawText) { + m_lastError = QStringLiteral("Tesseract GetUTF8Text 返回 null"); + return {}; + } + + const QString result = QString::fromUtf8(rawText).trimmed(); + delete[] rawText; + + m_api->Clear(); + m_lastError.clear(); + return result; +#else + if (!m_ready || m_tesseractProgram.isEmpty()) { + if (m_lastError.isEmpty()) { + m_lastError = QStringLiteral("OCR 未就绪:未找到 tesseract 可执行文件"); + } + return {}; + } + + if (image.isNull()) { + m_lastError = QStringLiteral("输入图像为空"); + return {}; + } + + QTemporaryDir tempDir; + if (!tempDir.isValid()) { + m_lastError = QStringLiteral("OCR 临时目录创建失败"); + return {}; + } + + const QString inputPath = tempDir.path() + QStringLiteral("/ocr_input.png"); + if (!image.save(inputPath, "PNG")) { + m_lastError = QStringLiteral("OCR 临时图片保存失败"); + return {}; + } + + QStringList args; + args << inputPath << QStringLiteral("stdout") << QStringLiteral("-l") << QStringLiteral("chi_sim+eng"); + if (!m_tessdataDir.isEmpty()) { + args << QStringLiteral("--tessdata-dir") << m_tessdataDir; + } + + QProcess process; + process.start(m_tesseractProgram, args); + if (!process.waitForStarted(5000)) { + m_lastError = QStringLiteral("OCR 启动失败:无法启动 tesseract 进程"); + return {}; + } + + if (!process.waitForFinished(30000)) { + process.kill(); + m_lastError = QStringLiteral("OCR 超时:tesseract 执行超过 30 秒"); + return {}; + } + + const QByteArray stdOut = process.readAllStandardOutput(); + const QByteArray stdErr = process.readAllStandardError(); + + if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0) { + const QString errText = QString::fromLocal8Bit(stdErr).trimmed(); + m_lastError = errText.isEmpty() + ? QStringLiteral("OCR 失败:tesseract 返回非零退出码") + : QStringLiteral("OCR 失败:%1").arg(errText); + return {}; + } + + const QString result = QString::fromUtf8(stdOut).trimmed(); + if (result.isEmpty()) { + const QString errText = QString::fromLocal8Bit(stdErr).trimmed(); + m_lastError = errText.isEmpty() + ? QStringLiteral("OCR 未识别到文本") + : QStringLiteral("OCR 未识别到文本:%1").arg(errText); + return {}; + } + + m_lastError.clear(); + return result; +#endif +} diff --git a/src/ocr/ocr_engine.h b/src/ocr/ocr_engine.h new file mode 100644 index 0000000..3a6e8fb --- /dev/null +++ b/src/ocr/ocr_engine.h @@ -0,0 +1,32 @@ +#ifndef OCR_ENGINE_H +#define OCR_ENGINE_H + +#include +#include + +namespace tesseract { +class TessBaseAPI; +} + +class OcrEngine +{ +public: + OcrEngine(); + ~OcrEngine(); + + // Synchronous OCR recognition, called from a worker thread. + // Returns trimmed text on success; empty string on failure. + QString recognize(const QImage &image); + + QString lastError() const { return m_lastError; } + bool isReady() const { return m_ready; } + +private: + tesseract::TessBaseAPI *m_api = nullptr; + bool m_ready = false; + QString m_lastError; + QString m_tesseractProgram; + QString m_tessdataDir; +}; + +#endif // OCR_ENGINE_H diff --git a/src/paint_board/paint_board.cpp b/src/paint_board/paint_board.cpp new file mode 100644 index 0000000..9d32c67 --- /dev/null +++ b/src/paint_board/paint_board.cpp @@ -0,0 +1,255 @@ +#include "paint_board.h" +#include "shape/mosaic_shape.h" +#include + +PaintBoard::PaintBoard(QQuickItem *parent) + : QQuickPaintedItem(parent) +{ + setAcceptedMouseButtons(Qt::LeftButton); +} + +void PaintBoard::setBackgroundImg(QImage img) +{ + m_background_img = img; + update(); +} + +void PaintBoard::addTextAnnotation(int x, int y, const QString& text) +{ + const QString resolvedText = text.trimmed().isEmpty() ? m_annotationText : text; + Shape* shape = m_factory.createTextShape( + QPoint(x, y), + resolvedText, + m_currentColor, + m_currentSize, + m_textBackgroundEnabled + ); + if (!shape) + return; + + m_shapes.append(shape); + update(); +} + +void PaintBoard::setShapeType(int type) +{ + const auto t = static_cast(type); + if (m_currentType == t) + return; + m_currentType = t; + emit shapeTypeChanged(type); +} + +void PaintBoard::setPenColor(const QColor &color) +{ + if (m_currentColor == color) + return; + m_currentColor = color; + emit penColorChanged(color); +} + +void PaintBoard::setPenSize(int size) +{ + if (m_currentSize == size) + return; + m_currentSize = size; + emit penSizeChanged(size); +} + +void PaintBoard::setAnnotationText(const QString& text) +{ + if (m_annotationText == text) + return; + m_annotationText = text; + emit annotationTextChanged(m_annotationText); +} + +void PaintBoard::setTextBackgroundEnabled(bool enabled) +{ + if (m_textBackgroundEnabled == enabled) + return; + m_textBackgroundEnabled = enabled; + emit textBackgroundEnabledChanged(enabled); +} + +void PaintBoard::setMosaicBlurLevel(int level) +{ + const int normalized = qBound(1, level, 4); + if (m_mosaicBlurLevel == normalized) + return; + m_mosaicBlurLevel = normalized; + emit mosaicBlurLevelChanged(m_mosaicBlurLevel); +} + +void PaintBoard::setNumberAutoIncrement(bool enabled) +{ + if (m_numberAutoIncrement == enabled) + return; + m_numberAutoIncrement = enabled; + emit numberAutoIncrementChanged(m_numberAutoIncrement); +} + +void PaintBoard::setNumberValue(int value) +{ + const int normalized = qBound(1, value, 9999); + if (m_numberValue == normalized) + return; + m_numberValue = normalized; + emit numberValueChanged(m_numberValue); +} + +void PaintBoard::undo() +{ + if (!m_shapes.isEmpty()) { + delete m_shapes.last(); + m_shapes.removeLast(); + update(); + } +} + +void PaintBoard::reset() +{ + qDeleteAll(m_shapes); + m_shapes.clear(); + + delete m_currentShape; + m_currentShape = nullptr; + m_drawing = false; + + m_background_img = QImage(); + update(); +} + +QImage PaintBoard::renderToImage() const +{ + if (m_shapes.isEmpty()) + return QImage(); + + QImage img(qRound(width()), qRound(height()), QImage::Format_ARGB32); + img.fill(Qt::transparent); + + QPainter painter(&img); + painter.setRenderHint(QPainter::Antialiasing); + for (Shape* shape : m_shapes) { + if (shape) + shape->draw(&painter); + } + painter.end(); + return img; +} + +void PaintBoard::paint(QPainter *painter) +{ + drawBkImg(painter, m_background_img); + drawShape(painter); +} + +void PaintBoard::mousePressEvent(QMouseEvent *event) +{ + m_startPoint = event->position().toPoint(); + m_drawing = true; + + if (m_currentType == TEXT) { + // Text insertion is handled by TZWindow popup flow. + m_drawing = false; + return; + } + + if (m_currentType == NUMBER) { + const int drawNumber = m_numberValue; + if (m_numberAutoIncrement) + setNumberValue(m_numberValue + 1); + + m_currentShape = m_factory.createNumberShape( + m_startPoint, + drawNumber, + m_currentColor, + m_currentSize + ); + m_shapes.append(m_currentShape); + m_currentShape = nullptr; + m_drawing = false; + update(); + return; + } + + if (m_currentType == MOSAIC) { + const int blurScale = qMax(1, m_mosaicBlurLevel); + auto* mosaicShape = new MosaicShape( + m_startPoint, + m_currentSize * 5 * blurScale, + qMax(2, m_currentSize * blurScale) + ); + + QImage snapshot = m_background_img; + qDebug() << "[PaintBoard][MOSAIC] press at" << m_startPoint + << "snapshotNull=" << snapshot.isNull() + << "snapshotSize=" << snapshot.size() + << "penSize=" << m_currentSize + << "blurLevel=" << m_mosaicBlurLevel; + mosaicShape->setCanvasSnapshot(snapshot); + m_currentShape = mosaicShape; + return; + } + + m_currentShape = m_factory.createShape( + m_currentType, + m_startPoint, + m_startPoint, + m_currentColor, + m_currentSize + ); +} + +void PaintBoard::mouseMoveEvent(QMouseEvent *event) +{ + if (!m_drawing || !m_currentShape) + return; + + m_currentShape->setEndPoint(event->position().toPoint()); + update(); +} + +void PaintBoard::mouseReleaseEvent(QMouseEvent *event) +{ + if (!m_drawing || !m_currentShape) + return; + + m_currentShape->setEndPoint(event->position().toPoint()); + m_shapes.append(m_currentShape); + m_currentShape = nullptr; + m_drawing = false; + update(); +} + +void PaintBoard::hoverMoveEvent(QHoverEvent *event) +{ + Q_UNUSED(event) +} + +bool PaintBoard::drawBkImg(QPainter *painter, QImage img) +{ + if (!painter || img.isNull()) + return false; + + QRectF drawRect = img.rect(); + drawRect.moveCenter(boundingRect().center()); + painter->drawImage(drawRect, img); + return true; +} + +bool PaintBoard::drawShape(QPainter *painter) +{ + if (!painter) + return false; + + for (Shape* shape : m_shapes) { + if (shape) + shape->draw(painter); + } + + if (m_currentShape) + m_currentShape->draw(painter); + + return true; +} diff --git a/src/paint_board/paint_board.h b/src/paint_board/paint_board.h new file mode 100644 index 0000000..bc303ef --- /dev/null +++ b/src/paint_board/paint_board.h @@ -0,0 +1,103 @@ +#ifndef PAINTBOARD_H +#define PAINTBOARD_H + +#include +#include +#include +#include +#include +#include + +#include "shape/shape.h" +#include "shape/shape_type.h" +#include "shape/shape_factory.h" +#include "shape/mosaic_shape.h" + +class PaintBoard : public QQuickPaintedItem +{ + Q_OBJECT + + Q_PROPERTY(int shapeType READ shapeType WRITE setShapeType NOTIFY shapeTypeChanged FINAL) + Q_PROPERTY(QColor penColor READ penColor WRITE setPenColor NOTIFY penColorChanged FINAL) + Q_PROPERTY(int penSize READ penSize WRITE setPenSize NOTIFY penSizeChanged FINAL) + Q_PROPERTY(QString annotationText READ annotationText WRITE setAnnotationText NOTIFY annotationTextChanged FINAL) + Q_PROPERTY(bool textBackgroundEnabled READ textBackgroundEnabled WRITE setTextBackgroundEnabled NOTIFY textBackgroundEnabledChanged FINAL) + Q_PROPERTY(int mosaicBlurLevel READ mosaicBlurLevel WRITE setMosaicBlurLevel NOTIFY mosaicBlurLevelChanged FINAL) + Q_PROPERTY(bool numberAutoIncrement READ numberAutoIncrement WRITE setNumberAutoIncrement NOTIFY numberAutoIncrementChanged FINAL) + Q_PROPERTY(int numberValue READ numberValue WRITE setNumberValue NOTIFY numberValueChanged FINAL) + +public: + explicit PaintBoard(QQuickItem *parent = nullptr); + + int shapeType() const { return static_cast(m_currentType); } + QColor penColor() const { return m_currentColor; } + int penSize() const { return m_currentSize; } + QString annotationText() const { return m_annotationText; } + bool textBackgroundEnabled() const { return m_textBackgroundEnabled; } + int mosaicBlurLevel() const { return m_mosaicBlurLevel; } + bool numberAutoIncrement() const { return m_numberAutoIncrement; } + int numberValue() const { return m_numberValue; } + + void setShapeType(int type); + void setPenColor(const QColor &color); + void setPenSize(int size); + void setAnnotationText(const QString& text); + void setTextBackgroundEnabled(bool enabled); + void setMosaicBlurLevel(int level); + void setNumberAutoIncrement(bool enabled); + void setNumberValue(int value); + +public: + Q_INVOKABLE void setBackgroundImg(QImage img); + Q_INVOKABLE bool hasBackgroundImg() const { return !m_background_img.isNull(); } + Q_INVOKABLE void addTextAnnotation(int x, int y, const QString& text); + + // Render all annotations onto a transparent image for screenshot merge. + Q_INVOKABLE QImage renderToImage() const; + + Q_INVOKABLE void undo(); + Q_INVOKABLE void reset(); // Clear all shapes and background image. + +signals: + void shapeTypeChanged(int shapeType); + void penColorChanged(QColor penColor); + void penSizeChanged(int penSize); + void annotationTextChanged(const QString& text); + void textBackgroundEnabledChanged(bool enabled); + void mosaicBlurLevelChanged(int level); + void numberAutoIncrementChanged(bool enabled); + void numberValueChanged(int value); + +protected: + void paint(QPainter *painter) override; + + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + + void hoverMoveEvent(QHoverEvent *event) override; + + bool drawBkImg(QPainter *painter, QImage img); + bool drawShape(QPainter *painter); + +private: + ShapeFactory m_factory; + + QList m_shapes; + Shape* m_currentShape = nullptr; + bool m_drawing = false; + QPoint m_startPoint; + + Shapeype m_currentType = RECTANGLE; + QColor m_currentColor = Qt::red; + int m_currentSize = 6; + QString m_annotationText = QStringLiteral("Text"); + bool m_textBackgroundEnabled = true; + int m_mosaicBlurLevel = 2; + bool m_numberAutoIncrement = true; + int m_numberValue = 1; + + QImage m_background_img; +}; + +#endif // PAINTBOARD_H diff --git a/src/paint_board/shape/arrow_shape.cpp b/src/paint_board/shape/arrow_shape.cpp new file mode 100644 index 0000000..97eea06 --- /dev/null +++ b/src/paint_board/shape/arrow_shape.cpp @@ -0,0 +1,48 @@ +#include "arrow_shape.h" + +ArrowShape::ArrowShape(const QPoint &startPoint, const QPoint &endPoint, + const QColor &color, int size) + : TwoPointShape(startPoint, endPoint, color, size) {} + +void ArrowShape::draw(QPainter *painter) +{ + if (!painter) return; + + // 起点和终点相同时不绘制 + if (m_startPoint == m_endPoint) return; + + painter->save(); + QPen pen(m_color, m_size); + pen.setCapStyle(Qt::RoundCap); + pen.setJoinStyle(Qt::RoundJoin); + painter->setPen(pen); + painter->setRenderHint(QPainter::Antialiasing); + + // 绘制箭杆 + painter->drawLine(m_startPoint, m_endPoint); + + // 计算箭头方向角 + const double angle = std::atan2( + m_endPoint.y() - m_startPoint.y(), + m_endPoint.x() - m_startPoint.x() + ); + + // 计算箭头两侧的点 + QPoint arrowP1( + m_endPoint.x() - static_cast(std::cos(angle + ARROW_HEAD_ANGLE) * ARROW_HEAD_LENGTH), + m_endPoint.y() - static_cast(std::sin(angle + ARROW_HEAD_ANGLE) * ARROW_HEAD_LENGTH) + ); + QPoint arrowP2( + m_endPoint.x() - static_cast(std::cos(angle - ARROW_HEAD_ANGLE) * ARROW_HEAD_LENGTH), + m_endPoint.y() - static_cast(std::sin(angle - ARROW_HEAD_ANGLE) * ARROW_HEAD_LENGTH) + ); + + // 绘制实心箭头头部 + QPolygon arrowHead; + arrowHead << m_endPoint << arrowP1 << arrowP2; + painter->setBrush(QBrush(m_color)); + painter->drawPolygon(arrowHead); + + painter->restore(); +} + diff --git a/src/paint_board/shape/arrow_shape.h b/src/paint_board/shape/arrow_shape.h new file mode 100644 index 0000000..71384fa --- /dev/null +++ b/src/paint_board/shape/arrow_shape.h @@ -0,0 +1,22 @@ +#ifndef ARROWSHAPE_H +#define ARROWSHAPE_H + +#include "two_point_shape.h" +#include + +class ArrowShape : public TwoPointShape +{ +public: + ArrowShape(const QPoint& startPoint = QPoint(0, 0), + const QPoint& endPoint = QPoint(0, 0), + const QColor& color = Qt::black, + int size = 2); + + void draw(QPainter* painter) override; + +private: + static constexpr double ARROW_HEAD_LENGTH = 15.0; // 箭头头部长度 + static constexpr double ARROW_HEAD_ANGLE = M_PI / 6.0; // 箭头张角 30° +}; + +#endif // ARROWSHAPE_H diff --git a/src/paint_board/shape/ellipse_shape.cpp b/src/paint_board/shape/ellipse_shape.cpp new file mode 100644 index 0000000..146bb5d --- /dev/null +++ b/src/paint_board/shape/ellipse_shape.cpp @@ -0,0 +1,19 @@ +#include "ellipse_shape.h" + +EllipseShape::EllipseShape(const QPoint &startPoint, const QPoint &endPoint, + const QColor &color, int size) + : TwoPointShape(startPoint, endPoint, color, size) {} + +void EllipseShape::draw(QPainter *painter) +{ + if (!painter) return; + painter->save(); + QPen pen(m_color, m_size); + painter->setPen(pen); + painter->setBrush(Qt::NoBrush); + painter->setRenderHint(QPainter::Antialiasing); + // 用两点构造外接矩形来绘制椭圆,normalized() 处理反向拖动 + painter->drawEllipse(QRect(m_startPoint, m_endPoint).normalized()); + painter->restore(); +} + diff --git a/src/paint_board/shape/ellipse_shape.h b/src/paint_board/shape/ellipse_shape.h new file mode 100644 index 0000000..98717da --- /dev/null +++ b/src/paint_board/shape/ellipse_shape.h @@ -0,0 +1,17 @@ +#ifndef CIRCLESHAPE_H +#define CIRCLESHAPE_H + +#include "two_point_shape.h" + +class EllipseShape : public TwoPointShape +{ +public: + EllipseShape(const QPoint& startPoint = QPoint(0, 0), + const QPoint& endPoint = QPoint(0, 0), + const QColor& color = Qt::black, + int size = 2); + + void draw(QPainter* painter) override; +}; + +#endif // CIRCLESHAPE_H diff --git a/src/paint_board/shape/mosaic_shape.cpp b/src/paint_board/shape/mosaic_shape.cpp new file mode 100644 index 0000000..67740bc --- /dev/null +++ b/src/paint_board/shape/mosaic_shape.cpp @@ -0,0 +1,115 @@ +#include "mosaic_shape.h" +#include + +MosaicShape::MosaicShape(const QPoint& startPoint, + int brushRadius, + int blockSize) + : Shape(Qt::transparent, blockSize) + , m_brushRadius(brushRadius) + , m_blockSize(blockSize) +{ + m_points.append(startPoint); + // 注意:此时还没有画布快照,需要在 setCanvasSnapshot 后才能真正应用马赛克 +} + +void MosaicShape::setCanvasSnapshot(const QImage& snapshot) +{ + if (snapshot.isNull()) + return; + + // 转换为 ARGB32 格式以便快速像素访问 + if (snapshot.format() != QImage::Format_ARGB32) { + m_canvasSnapshot = snapshot.convertToFormat(QImage::Format_ARGB32); + } else { + m_canvasSnapshot = snapshot; + } + + // 如果有待处理的点,现在应用马赛克 + if (!m_points.isEmpty() && m_blocks.isEmpty()) { + for (const QPoint& point : m_points) { + applyMosaicAt(point); + } + } +} + +void MosaicShape::setEndPoint(const QPoint& point) +{ + if (!m_points.isEmpty() && m_points.last() == point) + return; + m_points.append(point); + + // 只有在有画布快照的情况下才应用马赛克 + if (!m_canvasSnapshot.isNull()) { + applyMosaicAt(point); + } +} + +void MosaicShape::draw(QPainter* painter) +{ + if (!painter || m_blocks.isEmpty()) + return; + + painter->save(); + painter->setPen(Qt::NoPen); + + // 只绘制马赛克色块,不绘制任何背景像素,避免覆盖画板上其他图形 + for (auto it = m_blocks.constBegin(); it != m_blocks.constEnd(); ++it) { + const QPoint& blockOrigin = it.key(); + const QColor& color = it.value(); + + QRect blockRect(blockOrigin.x(), blockOrigin.y(), m_blockSize, m_blockSize); + + // 无需裁剪到背景图范围,因为我们不再依赖背景图 + // 但为了保证安全,可以检查坐标是否为正 + if (blockOrigin.x() < 0 || blockOrigin.y() < 0) + continue; + + painter->fillRect(blockRect, color); + } + + painter->restore(); +} + +void MosaicShape::applyMosaicAt(const QPoint& center) +{ + if (m_canvasSnapshot.isNull()) + return; + + int r = m_brushRadius; + int bs = qMax(1, m_blockSize); + int imgW = m_canvasSnapshot.width(); + int imgH = m_canvasSnapshot.height(); + + // 遍历圆形区域内所有马赛克块,步长为 bs + int startX = ((center.x() - r) / bs) * bs; + int startY = ((center.y() - r) / bs) * bs; + int endX = center.x() + r; + int endY = center.y() + r; + + for (int bx = startX; bx <= endX; bx += bs) { + for (int by = startY; by <= endY; by += bs) { + // 判断此块中心是否在笔刷圆形内 + int cx = bx + bs / 2; + int cy = by + bs / 2; + int dx = cx - center.x(); + int dy = cy - center.y(); + if (dx * dx + dy * dy > r * r) + continue; + + int x0 = qMax(0, bx); + int y0 = qMax(0, by); + if (x0 >= imgW || y0 >= imgH) + continue; + + QPoint blockKey(bx, by); + + // 已处理过的块不重复取色,保留首次取色 + if (m_blocks.contains(blockKey)) + continue; + + // 从画布快照取块内左上角像素作为代表色 + QRgb representative = m_canvasSnapshot.pixel(x0, y0); + m_blocks.insert(blockKey, QColor(representative)); + } + } +} diff --git a/src/paint_board/shape/mosaic_shape.h b/src/paint_board/shape/mosaic_shape.h new file mode 100644 index 0000000..7a69e8d --- /dev/null +++ b/src/paint_board/shape/mosaic_shape.h @@ -0,0 +1,49 @@ +#ifndef MOSAICSHAPE_H +#define MOSAICSHAPE_H + +#include "shape.h" +#include +#include +#include +#include +#include + +// QPoint 作为 QHash key 需要 qHash 支持 +inline uint qHash(const QPoint& p, uint seed = 0) +{ + return qHash(QPair(p.x(), p.y()), seed); +} + +// 马赛克图形:沿鼠标路径对当前画布做像素化处理 +// 不再依赖预生成的底图,而是实时从当前画布读取像素 +class MosaicShape : public Shape +{ +public: + // blockSize: 马赛克块的像素大小(笔刷半径区域内每块的边长) + MosaicShape(const QPoint& startPoint, + int brushRadius = 20, + int blockSize = 10); + + void setEndPoint(const QPoint& point) override; + + void draw(QPainter* painter) override; + + // 设置当前画布的快照,用于马赛克取色 + // 每次绘制前由 PaintBoard 调用 + void setCanvasSnapshot(const QImage& snapshot); + +private: + // 将 center 为中心、半径 m_brushRadius 的圆形区域内所有块写入 m_blocks + void applyMosaicAt(const QPoint& center); + + // key: 块左上角坐标(以 blockSize 对齐),value: 该块的代表色 + // 只存已被马赛克处理的块,draw() 时仅绘制这些色块 + QHash m_blocks; + + QImage m_canvasSnapshot; // 当前画布快照,实时获取 + QVector m_points; // 路径点列表 + int m_brushRadius; // 笔刷半径(像素) + int m_blockSize; // 每个马赛克块的边长(像素) +}; + +#endif // MOSAICSHAPE_H diff --git a/src/paint_board/shape/number_shape.cpp b/src/paint_board/shape/number_shape.cpp new file mode 100644 index 0000000..e4e3cad --- /dev/null +++ b/src/paint_board/shape/number_shape.cpp @@ -0,0 +1,44 @@ +#include "number_shape.h" + +#include +#include + +NumberShape::NumberShape(const QPoint& center, + int number, + const QColor& color, + int size) + : Shape(color, size) + , m_center(center) + , m_number(qMax(1, number)) +{ +} + +void NumberShape::setEndPoint(const QPoint& point) +{ + m_center = point; +} + +void NumberShape::draw(QPainter* painter) +{ + if (!painter) return; + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setRenderHint(QPainter::TextAntialiasing, true); + + const int r = qMax(10, m_size * 2 + 6); + const QRect circleRect(m_center.x() - r, m_center.y() - r, r * 2, r * 2); + + painter->setPen(QPen(Qt::white, qMax(2, m_size / 2))); + painter->setBrush(m_color); + painter->drawEllipse(circleRect); + + QFont f = painter->font(); + f.setBold(true); + f.setPixelSize(qMax(12, r)); + painter->setFont(f); + painter->setPen(Qt::white); + painter->drawText(circleRect, Qt::AlignCenter, QString::number(m_number)); + + painter->restore(); +} diff --git a/src/paint_board/shape/number_shape.h b/src/paint_board/shape/number_shape.h new file mode 100644 index 0000000..e891dfe --- /dev/null +++ b/src/paint_board/shape/number_shape.h @@ -0,0 +1,22 @@ +#ifndef NUMBER_SHAPE_H +#define NUMBER_SHAPE_H + +#include "shape.h" + +class NumberShape : public Shape +{ +public: + NumberShape(const QPoint& center, + int number, + const QColor& color, + int size); + + void setEndPoint(const QPoint& point) override; + void draw(QPainter* painter) override; + +private: + QPoint m_center; + int m_number = 1; +}; + +#endif // NUMBER_SHAPE_H diff --git a/src/paint_board/shape/pen_shape.cpp b/src/paint_board/shape/pen_shape.cpp new file mode 100644 index 0000000..5dcdbf5 --- /dev/null +++ b/src/paint_board/shape/pen_shape.cpp @@ -0,0 +1,39 @@ +#include "pen_shape.h" + +PenShape::PenShape(const QPoint &startPoint, const QColor &color, int size) + : Shape(color, size) +{ + m_points.append(startPoint); +} + +void PenShape::addPoint(const QPoint &point) +{ + // 避免重复添加相同的点 + if (!m_points.isEmpty() && m_points.last() == point) + return; + m_points.append(point); +} + +void PenShape::setEndPoint(const QPoint &point) +{ + addPoint(point); +} + +void PenShape::draw(QPainter *painter) +{ + if (!painter || m_points.size() < 2) + return; + + painter->save(); + + QPen pen(m_color, m_size); + pen.setCapStyle(Qt::RoundCap); // 端点圆滑 + pen.setJoinStyle(Qt::RoundJoin); // 转折点圆滑 + painter->setPen(pen); + painter->setRenderHint(QPainter::Antialiasing); + + // 将所有点连成折线 + painter->drawPolyline(QPolygon(m_points)); + + painter->restore(); +} diff --git a/src/paint_board/shape/pen_shape.h b/src/paint_board/shape/pen_shape.h new file mode 100644 index 0000000..689adf5 --- /dev/null +++ b/src/paint_board/shape/pen_shape.h @@ -0,0 +1,27 @@ +#ifndef PENSHAPE_H +#define PENSHAPE_H + +#include "shape.h" +#include + +// 画笔图形:记录鼠标经过的所有点,连线绘制 +class PenShape : public Shape +{ +public: + PenShape(const QPoint& startPoint, + const QColor& color = Qt::black, + int size = 2); + + // 追加一个路径点 + void addPoint(const QPoint& point); + + // 实现 Shape 纯虚函数,内部调用 addPoint + void setEndPoint(const QPoint& point) override; + + void draw(QPainter* painter) override; + +private: + QVector m_points; // 路径上所有点 +}; + +#endif // PENSHAPE_H diff --git a/src/paint_board/shape/rect_shape.cpp b/src/paint_board/shape/rect_shape.cpp new file mode 100644 index 0000000..6b9fc9b --- /dev/null +++ b/src/paint_board/shape/rect_shape.cpp @@ -0,0 +1,19 @@ +#include "rect_shape.h" + +RectShape::RectShape(const QPoint &startPoint, const QPoint &endPoint, + const QColor &color, int size) + : TwoPointShape(startPoint, endPoint, color, size) {} + +void RectShape::draw(QPainter *painter) +{ + if (!painter) return; + painter->save(); + QPen pen(m_color, m_size); + painter->setPen(pen); + painter->setBrush(Qt::NoBrush); + painter->setRenderHint(QPainter::Antialiasing); + // normalized() 处理从右下往左上拖动的情况 + painter->drawRect(QRect(m_startPoint, m_endPoint).normalized()); + painter->restore(); +} + diff --git a/src/paint_board/shape/rect_shape.h b/src/paint_board/shape/rect_shape.h new file mode 100644 index 0000000..a8f5836 --- /dev/null +++ b/src/paint_board/shape/rect_shape.h @@ -0,0 +1,16 @@ +#ifndef RECTSHAPE_H +#define RECTSHAPE_H +#include "two_point_shape.h" + +class RectShape : public TwoPointShape +{ +public: + RectShape(const QPoint& startPoint = QPoint(0, 0), + const QPoint& endPoint = QPoint(0, 0), + const QColor& color = Qt::black, + int size = 2); + + void draw(QPainter* painter) override; +}; + +#endif // RECTSHAPE_H diff --git a/src/paint_board/shape/shape.cpp b/src/paint_board/shape/shape.cpp new file mode 100644 index 0000000..3100a2d --- /dev/null +++ b/src/paint_board/shape/shape.cpp @@ -0,0 +1,5 @@ +#include "shape.h" + +Shape::Shape(const QColor &color, int size) + : m_color(color), m_size(size) {} + diff --git a/src/paint_board/shape/shape.h b/src/paint_board/shape/shape.h new file mode 100644 index 0000000..e619683 --- /dev/null +++ b/src/paint_board/shape/shape.h @@ -0,0 +1,29 @@ +#ifndef SHAPE_H +#define SHAPE_H +#include +#include +#include + +class Shape +{ +public: + Shape(const QColor& color = Qt::black, int size = 2); + + virtual ~Shape() = default; + // 纯虚函数:统一的绘制接口(子类必须实现) + virtual void draw(QPainter* painter) = 0; + // 纯虚函数:更新终点(用于绘制预览) + virtual void setEndPoint(const QPoint& point) = 0; + + void setColor(const QColor& color) { m_color = color; } + QColor color() const { return m_color; } + + void setSize(int size) { m_size = size; } + int size() const { return m_size; } + +protected: + QColor m_color; + int m_size; +}; + +#endif // SHAPE_H diff --git a/src/paint_board/shape/shape_factory.cpp b/src/paint_board/shape/shape_factory.cpp new file mode 100644 index 0000000..09ba10c --- /dev/null +++ b/src/paint_board/shape/shape_factory.cpp @@ -0,0 +1,53 @@ +#include "shape_factory.h" +#include "rect_shape.h" +#include "arrow_shape.h" +#include "ellipse_shape.h" +#include "pen_shape.h" +#include "text_shape.h" +#include "number_shape.h" + +ShapeFactory::ShapeFactory() {} + +Shape* ShapeFactory::createShape(Shapeype type, + const QPoint& startPoint, + const QPoint& endPoint, + const QColor& color, + int size) +{ + switch (type) + { + case PEN: + // Freehand pen only needs the start point. + return new PenShape(startPoint, color, size); + case RECTANGLE: + return new RectShape(startPoint, endPoint, color, size); + case ELLIPSE: + return new EllipseShape(startPoint, endPoint, color, size); + case ARROW: + return new ArrowShape(startPoint, endPoint, color, size); + case HIGHLIGHT: { + QColor hi = color; + hi.setAlpha(100); + return new PenShape(startPoint, hi, qMax(8, size * 2)); + } + default: + return nullptr; + } +} + +Shape* ShapeFactory::createTextShape(const QPoint& startPoint, + const QString& text, + const QColor& color, + int size, + bool withBackground) +{ + return new TextShape(startPoint, text, color, size, withBackground); +} + +Shape* ShapeFactory::createNumberShape(const QPoint& center, + int number, + const QColor& color, + int size) +{ + return new NumberShape(center, number, color, size); +} diff --git a/src/paint_board/shape/shape_factory.h b/src/paint_board/shape/shape_factory.h new file mode 100644 index 0000000..81f87b2 --- /dev/null +++ b/src/paint_board/shape/shape_factory.h @@ -0,0 +1,33 @@ +#ifndef SHAPEFACTORY_H +#define SHAPEFACTORY_H + +#include "shape.h" +#include "shape_type.h" +#include +#include +#include + +class ShapeFactory +{ +public: + ShapeFactory(); + + Shape* createShape(Shapeype type, + const QPoint& startPoint, + const QPoint& endPoint, + const QColor& color, + int size); + + Shape* createTextShape(const QPoint& startPoint, + const QString& text, + const QColor& color, + int size, + bool withBackground); + + Shape* createNumberShape(const QPoint& center, + int number, + const QColor& color, + int size); +}; + +#endif // SHAPEFACTORY_H diff --git a/src/paint_board/shape/shape_type.h b/src/paint_board/shape/shape_type.h new file mode 100644 index 0000000..f811c13 --- /dev/null +++ b/src/paint_board/shape/shape_type.h @@ -0,0 +1,16 @@ +#ifndef SHAPETYPE_H +#define SHAPETYPE_H + +enum Shapeype +{ + PEN, + RECTANGLE, + ELLIPSE, + ARROW, + MOSAIC, + TEXT, + NUMBER, + HIGHLIGHT +}; + +#endif // SHAPETYPE_H diff --git a/src/paint_board/shape/text_shape.cpp b/src/paint_board/shape/text_shape.cpp new file mode 100644 index 0000000..d23c0e0 --- /dev/null +++ b/src/paint_board/shape/text_shape.cpp @@ -0,0 +1,55 @@ +#include "text_shape.h" + +#include +#include + +TextShape::TextShape(const QPoint& startPoint, + const QString& text, + const QColor& color, + int size, + bool withBackground) + : Shape(color, size) + , m_point(startPoint) + , m_text(text.isEmpty() ? QStringLiteral("Text") : text) + , m_withBackground(withBackground) +{ +} + +void TextShape::setEndPoint(const QPoint& point) +{ + m_point = point; +} + +void TextShape::draw(QPainter* painter) +{ + if (!painter) return; + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setRenderHint(QPainter::TextAntialiasing, true); + + QFont f = painter->font(); + f.setPixelSize(qMax(12, m_size * 4)); + painter->setFont(f); + + const QFontMetrics fm(f); + const QRect textRect = fm.boundingRect(m_text); + const int padX = qMax(4, m_size); + const int padY = qMax(2, m_size / 2); + QRect drawRect(m_point.x(), + m_point.y() - textRect.height(), + textRect.width() + padX * 2, + textRect.height() + padY * 2); + + if (m_withBackground) { + QColor bg(0, 0, 0, 140); + painter->setPen(Qt::NoPen); + painter->setBrush(bg); + painter->drawRoundedRect(drawRect, 4, 4); + } + + painter->setPen(m_color); + const QPoint textPos(drawRect.x() + padX, drawRect.y() + padY + fm.ascent()); + painter->drawText(textPos, m_text); + painter->restore(); +} diff --git a/src/paint_board/shape/text_shape.h b/src/paint_board/shape/text_shape.h new file mode 100644 index 0000000..3d2f45d --- /dev/null +++ b/src/paint_board/shape/text_shape.h @@ -0,0 +1,25 @@ +#ifndef TEXT_SHAPE_H +#define TEXT_SHAPE_H + +#include "shape.h" +#include + +class TextShape : public Shape +{ +public: + TextShape(const QPoint& startPoint, + const QString& text, + const QColor& color, + int size, + bool withBackground); + + void setEndPoint(const QPoint& point) override; + void draw(QPainter* painter) override; + +private: + QPoint m_point; + QString m_text; + bool m_withBackground = true; +}; + +#endif // TEXT_SHAPE_H diff --git a/src/paint_board/shape/two_point_shape.cpp b/src/paint_board/shape/two_point_shape.cpp new file mode 100644 index 0000000..1a92d76 --- /dev/null +++ b/src/paint_board/shape/two_point_shape.cpp @@ -0,0 +1,25 @@ +#include "two_point_shape.h" + +TwoPointShape::TwoPointShape(const QPoint &startPoint, const QPoint &endPoint, + const QColor &color, int size) + : Shape(color, size), m_startPoint(startPoint), m_endPoint(endPoint) {} + +void TwoPointShape::setStartPoint(const QPoint &point) +{ + m_startPoint = point; +} + +QPoint TwoPointShape::startPoint() const +{ + return m_startPoint; +} + +void TwoPointShape::setEndPoint(const QPoint &point) +{ + m_endPoint = point; +} + +QPoint TwoPointShape::endPoint() const +{ + return m_endPoint; +} diff --git a/src/paint_board/shape/two_point_shape.h b/src/paint_board/shape/two_point_shape.h new file mode 100644 index 0000000..2fd095f --- /dev/null +++ b/src/paint_board/shape/two_point_shape.h @@ -0,0 +1,27 @@ +#ifndef TWOPOINTSHAPE_H +#define TWOPOINTSHAPE_H + +#include "shape.h" + +// 两点图形基类:所有由起点+终点确定的图形(矩形、椭圆、箭头)均继承此类 +class TwoPointShape : public Shape +{ +public: + TwoPointShape(const QPoint& startPoint, + const QPoint& endPoint, + const QColor& color = Qt::black, + int size = 2); + + void setStartPoint(const QPoint& point); + QPoint startPoint() const; + + // 实现 Shape 的纯虚函数 + void setEndPoint(const QPoint& point) override; + QPoint endPoint() const; + +protected: + QPoint m_startPoint; + QPoint m_endPoint; +}; + +#endif // TWOPOINTSHAPE_H diff --git a/src/shortcut_key/globalshortcut.cpp b/src/shortcut_key/globalshortcut.cpp new file mode 100644 index 0000000..003c26f --- /dev/null +++ b/src/shortcut_key/globalshortcut.cpp @@ -0,0 +1,383 @@ +#include "globalshortcut.h" + +#include +#include +#include +#include + +/* ── 默认快捷键 ─────────────────────────────────────── */ +const char* GlobalShortcut::kDefaults[5] = { + "", /* index 0 未使用 */ + "Alt+A", /* 1: 截图 */ + "Alt+S", /* 2: 截图并保存 */ + "Alt+P", /* 3: 贴图到桌面 */ + "Alt+Q", /* 4: 显示/隐藏窗口 */ +}; + +/* ── QSettings 键名 ─────────────────────────────────── */ +static QString settingsKey(int actionId) +{ + switch (actionId) { + case 1: return QStringLiteral("Shortcuts/screenshot"); + case 2: return QStringLiteral("Shortcuts/screenshotSave"); + case 3: return QStringLiteral("Shortcuts/sticky"); + case 4: return QStringLiteral("Shortcuts/toggle"); + } + return QString(); +} + +/* ── QSettings 读写 ─────────────────────────────────── */ +QString GlobalShortcut::loadShortcut(int actionId, const QString &defaultSeq) const +{ + QSettings s; + return s.value(settingsKey(actionId), defaultSeq).toString(); +} + +void GlobalShortcut::saveShortcut(int actionId, const QString &keySeq) const +{ + QSettings s; + s.setValue(settingsKey(actionId), keySeq); +} + +/* ── QML 属性读取 ────────────────────────────────────── */ +QString GlobalShortcut::shortcutScreenshot() const { return m_shortcuts.value(1); } +QString GlobalShortcut::shortcutScreenshotSave() const { return m_shortcuts.value(2); } +QString GlobalShortcut::shortcutSticky() const { return m_shortcuts.value(3); } +QString GlobalShortcut::shortcutToggle() const { return m_shortcuts.value(4); } + +/* ── buildKeySequence(QML 调用,跨平台通用) ───────── */ +QString GlobalShortcut::buildKeySequence(int key, int modifiers) +{ + QStringList parts; + if (modifiers & Qt::ControlModifier) parts << "Ctrl"; + if (modifiers & Qt::ShiftModifier) parts << "Shift"; + if (modifiers & Qt::AltModifier) parts << "Alt"; + if (modifiers & Qt::MetaModifier) parts << "Meta"; + + QString keyName = QKeySequence(key).toString(); + if (keyName.isEmpty()) return QString(); + + /* 过滤掉单独按修饰键的情况 */ + if (keyName == "Ctrl" || keyName == "Shift" || + keyName == "Alt" || keyName == "Meta") + return QString(); + + parts << keyName; + return parts.join('+'); +} + +/* ── checkConflict(QML 调用) ───────────────────────── */ +int GlobalShortcut::checkConflict(const QString &keySeq) const +{ + for (auto it = m_shortcuts.constBegin(); it != m_shortcuts.constEnd(); ++it) { + if (it.value().compare(keySeq, Qt::CaseInsensitive) == 0) + return it.key(); + } + return 0; +} + +/* ── updateShortcut(QML 调用) ──────────────────────── */ +bool GlobalShortcut::updateShortcut(int actionId, const QString &keySeq) +{ + if (actionId < 1 || actionId > 4 || keySeq.isEmpty()) return false; + + if (!applyShortcut(actionId, keySeq)) return false; + + m_shortcuts[actionId] = keySeq; + saveShortcut(actionId, keySeq); + + switch (actionId) { + case 1: emit shortcutScreenshotChanged(); break; + case 2: emit shortcutScreenshotSaveChanged(); break; + case 3: emit shortcutStickyChanged(); break; + case 4: emit shortcutToggleChanged(); break; + } + + qInfo() << "[GlobalShortcut] 更新快捷键 actionId=" << actionId << "->" << keySeq; + return true; +} + +/* ══════════════════════════════════════════════════════════ + * 以下部分按平台分别实现 + * ══════════════════════════════════════════════════════════ */ + +/* ──────────────────────────────────────────────────────── + * UNIX / Linux 实现(XCB + pipe + QSocketNotifier) + * ──────────────────────────────────────────────────────── */ +#ifdef Q_OS_UNIX + +#include "linux_grab.h" +#include +#include +#include + +/* XK_ keysym 映射(常用键) */ +static unsigned long keyNameToKeySym(const QString &key) +{ + if (key.length() == 1) { + QChar c = key.at(0).toLower(); + if (c >= 'a' && c <= 'z') + return (unsigned long)(0x0061 + (c.toLatin1() - 'a')); + if (c >= '0' && c <= '9') + return (unsigned long)(0x0030 + (c.toLatin1() - '0')); + } + if (key.startsWith("F", Qt::CaseInsensitive) && key.length() <= 3) { + bool ok = false; + int n = key.mid(1).toInt(&ok); + if (ok && n >= 1 && n <= 12) + return (unsigned long)(0xFFBE + n - 1); + } + if (key == "Return" || key == "Enter") return 0xFF0D; + if (key == "Escape" || key == "Esc") return 0xFF1B; + if (key == "Space") return 0x0020; + if (key == "Tab") return 0xFF09; + if (key == "Backspace") return 0xFF08; + if (key == "Delete" || key == "Del") return 0xFFFF; + if (key == "Insert" || key == "Ins") return 0xFF63; + if (key == "Home") return 0xFF50; + if (key == "End") return 0xFF57; + if (key == "PageUp") return 0xFF55; + if (key == "PageDown") return 0xFF56; + if (key == "Left") return 0xFF51; + if (key == "Right") return 0xFF53; + if (key == "Up") return 0xFF52; + if (key == "Down") return 0xFF54; + return 0; +} + +quint16 GlobalShortcut::modifierToXcbMask(const QString &mod) +{ + if (mod == "Ctrl" || mod == "Control") return XCB_MOD_MASK_CONTROL; + if (mod == "Shift") return XCB_MOD_MASK_SHIFT; + if (mod == "Alt" || mod == "Meta") return XCB_MOD_MASK_1; + if (mod == "Super" || mod == "Win") return XCB_MOD_MASK_4; + return 0; +} + +bool GlobalShortcut::applyShortcut(int actionId, const QString &keySeq) +{ + if (keySeq.isEmpty()) return false; + + QStringList parts = keySeq.split('+', Qt::SkipEmptyParts); + if (parts.isEmpty()) return false; + + QString mainKey = parts.last(); + quint16 mods = 0; + for (int i = 0; i < parts.size() - 1; i++) { + mods = (quint16)(mods | modifierToXcbMask(parts.at(i))); + } + + unsigned long keysym = keyNameToKeySym(mainKey); + if (keysym == 0) { + qWarning() << "[GlobalShortcut] 无法识别主键:" << mainKey; + return false; + } + + unsigned int keycode = linux_grab_keysym_to_keycode(keysym); + if (keycode == 0) { + qWarning() << "[GlobalShortcut] keycode 查询失败:" << mainKey; + return false; + } + + linux_grab_register(keycode, mods, (uint8_t)actionId); + qInfo() << "[GlobalShortcut] 注册快捷键" << keySeq + << "→ actionId=" << actionId + << "keycode=" << keycode << "mods=" << mods; + return true; +} + +GlobalShortcut::GlobalShortcut(QObject *parent) + : QObject(parent) +{ + int fds[2]; + if (pipe(fds) != 0) { + qCritical() << "[GlobalShortcut] pipe 创建失败"; + return; + } + m_readFd = fds[0]; + m_writeFd = fds[1]; + + if (!linux_grab_init(m_writeFd)) { + qCritical() << "[GlobalShortcut] linux_grab_init 失败"; + ::close(m_readFd); + ::close(m_writeFd); + m_readFd = m_writeFd = -1; + return; + } + + for (int id = 1; id <= 4; id++) { + QString seq = loadShortcut(id, QString::fromLatin1(kDefaults[id])); + m_shortcuts[id] = seq; + applyShortcut(id, seq); + } + + m_notifier = new QSocketNotifier(m_readFd, QSocketNotifier::Read, this); + connect(m_notifier, &QSocketNotifier::activated, + this, &GlobalShortcut::onPipeReadable); + + qInfo() << "[GlobalShortcut] Unix 初始化完成"; +} + +GlobalShortcut::~GlobalShortcut() +{ + linux_grab_cleanup(); + if (m_readFd >= 0) { ::close(m_readFd); m_readFd = -1; } + if (m_writeFd >= 0) { ::close(m_writeFd); m_writeFd = -1; } +} + +void GlobalShortcut::onPipeReadable() +{ + uint8_t evt = 0; + if (::read(m_readFd, &evt, 1) != 1) return; + + qDebug() << "[GlobalShortcut] 触发事件 id=" << evt; + + switch (evt) { + case LINUX_GRAB_EVENT_SCREENSHOT: emit screenshotActivated(); break; + case LINUX_GRAB_EVENT_SCREENSHOT_SAVE: emit screenshotSaveActivated(); break; + case LINUX_GRAB_EVENT_STICKY: emit stickyActivated(); break; + case LINUX_GRAB_EVENT_TOGGLE: emit toggleActivated(); break; + default: break; + } +} + +bool GlobalShortcut::nativeEventFilter(const QByteArray &, void *, qintptr *) +{ + return false; /* Unix 通过 pipe 分发,此处不处理 */ +} + +#endif /* Q_OS_UNIX */ + +/* ──────────────────────────────────────────────────────── + * Windows 实现(RegisterHotKey + WM_HOTKEY) + * ──────────────────────────────────────────────────────── */ +#ifdef Q_OS_WIN + +#include "win_grab.h" +#define WIN32_LEAN_AND_MEAN +#include + +/* 键名 → Windows 虚拟键码 */ +unsigned int GlobalShortcut::keyNameToVk(const QString &key) +{ + /* 字母 A-Z */ + if (key.length() == 1) { + QChar c = key.at(0).toUpper(); + if (c >= 'A' && c <= 'Z') return (unsigned int)c.toLatin1(); /* VK_A = 'A' */ + if (c >= '0' && c <= '9') return (unsigned int)c.toLatin1(); /* VK_0 = '0' */ + } + /* F1-F12 */ + if (key.startsWith("F", Qt::CaseInsensitive) && key.length() <= 3) { + bool ok = false; + int n = key.mid(1).toInt(&ok); + if (ok && n >= 1 && n <= 12) return (unsigned int)(VK_F1 + n - 1); + } + /* 特殊键 */ + if (key == "Return" || key == "Enter") return VK_RETURN; + if (key == "Escape" || key == "Esc") return VK_ESCAPE; + if (key == "Space") return VK_SPACE; + if (key == "Tab") return VK_TAB; + if (key == "Backspace") return VK_BACK; + if (key == "Delete" || key == "Del") return VK_DELETE; + if (key == "Insert" || key == "Ins") return VK_INSERT; + if (key == "Home") return VK_HOME; + if (key == "End") return VK_END; + if (key == "PageUp" || key == "PgUp") return VK_PRIOR; + if (key == "PageDown" || key == "PgDown") return VK_NEXT; + if (key == "Left") return VK_LEFT; + if (key == "Right") return VK_RIGHT; + if (key == "Up") return VK_UP; + if (key == "Down") return VK_DOWN; + if (key == "PrintScreen") return VK_SNAPSHOT; + if (key == "Pause") return VK_PAUSE; + if (key == "CapsLock") return VK_CAPITAL; + if (key == "NumLock") return VK_NUMLOCK; + if (key == "ScrollLock") return VK_SCROLL; + return 0; +} + +/* 修饰键字符串 → Windows MOD_* 掩码 */ +unsigned int GlobalShortcut::modifierToWinMask(const QString &mod) +{ + if (mod == "Ctrl" || mod == "Control") return MOD_CONTROL; + if (mod == "Shift") return MOD_SHIFT; + if (mod == "Alt" || mod == "Meta") return MOD_ALT; + if (mod == "Win" || mod == "Super") return MOD_WIN; + return 0; +} + +bool GlobalShortcut::applyShortcut(int actionId, const QString &keySeq) +{ + if (keySeq.isEmpty()) return false; + + QStringList parts = keySeq.split('+', Qt::SkipEmptyParts); + if (parts.isEmpty()) return false; + + QString mainKey = parts.last(); + unsigned int mods = 0; + for (int i = 0; i < parts.size() - 1; i++) { + mods |= modifierToWinMask(parts.at(i)); + } + + unsigned int vk = keyNameToVk(mainKey); + if (vk == 0) { + qWarning() << "[GlobalShortcut] 无法识别主键:" << mainKey; + return false; + } + + win_grab_register(vk, mods, (uint8_t)actionId); + qInfo() << "[GlobalShortcut] 注册快捷键" << keySeq + << "→ actionId=" << actionId + << "vk=0x" << Qt::hex << vk << "mods=0x" << mods; + return true; +} + +GlobalShortcut::GlobalShortcut(QObject *parent) + : QObject(parent) +{ + win_grab_init(); + + for (int id = 1; id <= 4; id++) { + QString seq = loadShortcut(id, QString::fromLatin1(kDefaults[id])); + m_shortcuts[id] = seq; + applyShortcut(id, seq); + } + + /* 将自身注册为 Qt 原生事件过滤器,以便接收 WM_HOTKEY */ + qApp->installNativeEventFilter(this); + + qInfo() << "[GlobalShortcut] Windows 初始化完成"; +} + +GlobalShortcut::~GlobalShortcut() +{ + qApp->removeNativeEventFilter(this); + win_grab_cleanup(); +} + +bool GlobalShortcut::nativeEventFilter(const QByteArray &eventType, + void *message, qintptr *result) +{ + Q_UNUSED(result) + if (eventType != "windows_generic_MSG" && eventType != "windows_dispatcher_MSG") + return false; + + MSG *msg = static_cast(message); + if (!msg || msg->message != WM_HOTKEY) + return false; + + uint8_t evt = (uint8_t)msg->wParam; /* wParam = hotkey id = actionId */ + + qDebug() << "[GlobalShortcut] WM_HOTKEY 触发事件 id=" << evt; + + switch (evt) { + case WIN_GRAB_EVENT_SCREENSHOT: emit screenshotActivated(); break; + case WIN_GRAB_EVENT_SCREENSHOT_SAVE: emit screenshotSaveActivated(); break; + case WIN_GRAB_EVENT_STICKY: emit stickyActivated(); break; + case WIN_GRAB_EVENT_TOGGLE: emit toggleActivated(); break; + default: return false; + } + return true; /* 已处理,不再传递 */ +} + +#endif /* Q_OS_WIN */ diff --git a/src/shortcut_key/globalshortcut.h b/src/shortcut_key/globalshortcut.h new file mode 100644 index 0000000..5bde676 --- /dev/null +++ b/src/shortcut_key/globalshortcut.h @@ -0,0 +1,116 @@ +#ifndef GLOBALSHORTCUT_H +#define GLOBALSHORTCUT_H + +#include +#include +#include +#include + +#ifdef Q_OS_UNIX +QT_FORWARD_DECLARE_CLASS(QSocketNotifier) +#endif + +/* + * GlobalShortcut — 全局快捷键管理器(跨平台) + * + * 支持 4 个功能动作的快捷键注册、动态修改与 QSettings 持久化。 + * 快捷键格式:标准 Qt 序列字符串,如 "Alt+A"、"Ctrl+Shift+1"。 + * + * 动作 ID: + * 1 = 截图 + * 2 = 截图并保存 + * 3 = 贴图到桌面 + * 4 = 显示/隐藏窗口 + * + * 平台实现: + * Linux/Unix — XCB xcb_grab_key + pipe + QSocketNotifier + * Windows — RegisterHotKey + QAbstractNativeEventFilter (WM_HOTKEY) + */ +class GlobalShortcut : public QObject, public QAbstractNativeEventFilter +{ + Q_OBJECT + + Q_PROPERTY(QString shortcutScreenshot READ shortcutScreenshot NOTIFY shortcutScreenshotChanged) + Q_PROPERTY(QString shortcutScreenshotSave READ shortcutScreenshotSave NOTIFY shortcutScreenshotSaveChanged) + Q_PROPERTY(QString shortcutSticky READ shortcutSticky NOTIFY shortcutStickyChanged) + Q_PROPERTY(QString shortcutToggle READ shortcutToggle NOTIFY shortcutToggleChanged) + +public: + explicit GlobalShortcut(QObject *parent = nullptr); + ~GlobalShortcut(); + + /* 读取各动作当前快捷键字符串(供 QML 属性绑定) */ + QString shortcutScreenshot() const; + QString shortcutScreenshotSave() const; + QString shortcutSticky() const; + QString shortcutToggle() const; + + /* + * 更新某个动作的快捷键(供 QML 调用) + * actionId : 1~4 + * keySeq : 如 "Alt+A"、"Ctrl+Shift+1"、"F1" + * 返回 true 表示解析并注册成功 + */ + Q_INVOKABLE bool updateShortcut(int actionId, const QString &keySeq); + + /* 检查某个快捷键字符串是否已被其他动作占用(0 = 未占用,否则返回占用的 actionId) */ + Q_INVOKABLE int checkConflict(const QString &keySeq) const; + + /* 将 Qt 按键事件中的 key + modifiers 转换为快捷键字符串(供 QML 录制用) */ + Q_INVOKABLE static QString buildKeySequence(int key, int modifiers); + +signals: + /* 4 个功能信号,由 main.qml 连接到对应操作 */ + void screenshotActivated(); + void screenshotSaveActivated(); + void stickyActivated(); + void toggleActivated(); + + /* 快捷键变更通知,供 QML 属性绑定刷新 */ + void shortcutScreenshotChanged(); + void shortcutScreenshotSaveChanged(); + void shortcutStickyChanged(); + void shortcutToggleChanged(); + +protected: + bool nativeEventFilter(const QByteArray &eventType, + void *message, qintptr *result) override; + +private slots: +#ifdef Q_OS_UNIX + void onPipeReadable(); +#endif + +private: + /* 解析 keySeq,注册到平台后端,写入 QSettings */ + bool applyShortcut(int actionId, const QString &keySeq); + + /* 读取 QSettings,缺失时使用 defaultSeq */ + QString loadShortcut(int actionId, const QString &defaultSeq) const; + + /* 写入 QSettings */ + void saveShortcut(int actionId, const QString &keySeq) const; + + /* 当前各动作的快捷键字符串,key = actionId (1~4) */ + QMap m_shortcuts; + + /* 默认快捷键 */ + static const char* kDefaults[5]; /* index 1..4 */ + +#ifdef Q_OS_UNIX + /* ── Unix 专用:pipe + QSocketNotifier ─────────────── */ + static quint16 modifierToXcbMask(const QString &mod); + + QSocketNotifier *m_notifier = nullptr; + int m_readFd = -1; + int m_writeFd = -1; +#endif + +#ifdef Q_OS_WIN + /* ── Windows 专用:VK 键码转换 ─────────────────────── */ + static unsigned int keyNameToVk(const QString &key); + static unsigned int modifierToWinMask(const QString &mod); +#endif +}; + +#endif // GLOBALSHORTCUT_H diff --git a/src/shortcut_key/linux_grab.c b/src/shortcut_key/linux_grab.c new file mode 100644 index 0000000..81340bf --- /dev/null +++ b/src/shortcut_key/linux_grab.c @@ -0,0 +1,198 @@ +/* 纯 C 翻译单元:独立 XCB 连接 + 线程监听按键,通过 pipe 通知 Qt */ +#include "linux_grab.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +/* ── 动态快捷键表 ─────────────────────────────────────── */ +typedef struct { + uint8_t keycode; + uint16_t mods; + uint8_t event_id; /* 0 表示该槽位空闲 */ +} LinuxShortcutEntry; + +static LinuxShortcutEntry s_shortcuts[LINUX_GRAB_MAX_SHORTCUTS]; +static int s_shortcut_count = 0; +static pthread_mutex_t s_mutex = PTHREAD_MUTEX_INITIALIZER; + +/* ── XCB 全局状态 ─────────────────────────────────────── */ +static xcb_connection_t *s_conn = NULL; +static xcb_window_t s_root = 0; +static pthread_t s_thread; +static int s_notify_fd = -1; +static volatile int s_running = 0; + +/* NumLock / CapsLock 修饰键组合,每个快捷键注册 4 次 */ +static const uint16_t kExtraMods[] = { + 0, + XCB_MOD_MASK_2, /* NumLock */ + XCB_MOD_MASK_LOCK, /* CapsLock */ + XCB_MOD_MASK_2 | XCB_MOD_MASK_LOCK +}; +#define EXTRA_MODS_COUNT 4 + +/* ── 内部辅助函数 ─────────────────────────────────────── */ + +static void do_grab_key(uint8_t keycode, uint16_t mods) +{ + unsigned int i; + for (i = 0; i < EXTRA_MODS_COUNT; i++) { + xcb_grab_key(s_conn, + 0, /* owner_events = 0 */ + s_root, + (uint16_t)(mods | kExtraMods[i]), + keycode, + XCB_GRAB_MODE_ASYNC, + XCB_GRAB_MODE_ASYNC); + } +} + +static void do_ungrab_key(uint8_t keycode, uint16_t mods) +{ + unsigned int i; + for (i = 0; i < EXTRA_MODS_COUNT; i++) { + xcb_ungrab_key(s_conn, + keycode, + s_root, + (uint16_t)(mods | kExtraMods[i])); + } +} + +/* ── 监听线程 ─────────────────────────────────────────── */ + +static void *event_thread(void *arg) +{ + (void)arg; + while (s_running) { + xcb_generic_event_t *ev = xcb_wait_for_event(s_conn); + if (!ev) break; /* 连接断开,线程退出 */ + + if ((ev->response_type & ~0x80) == XCB_KEY_PRESS) { + xcb_key_press_event_t *kev = (xcb_key_press_event_t *)ev; + uint8_t kc = kev->detail; + /* 屏蔽 NumLock / CapsLock,避免影响匹配 */ + uint16_t mods = (uint16_t)(kev->state & + ~(uint16_t)(XCB_MOD_MASK_2 | XCB_MOD_MASK_LOCK)); + + uint8_t evt = 0; + int i; + pthread_mutex_lock(&s_mutex); + for (i = 0; i < s_shortcut_count; i++) { + if (s_shortcuts[i].event_id != 0 && + s_shortcuts[i].keycode == kc && + s_shortcuts[i].mods == mods) { + evt = s_shortcuts[i].event_id; + break; + } + } + pthread_mutex_unlock(&s_mutex); + + if (evt != 0) { + write(s_notify_fd, &evt, 1); + } + } + free(ev); + } + return NULL; +} + +/* ── 公共接口实现 ─────────────────────────────────────── */ + +unsigned int linux_grab_keysym_to_keycode(unsigned long keysym) +{ + unsigned int kc = 0; + Display *dpy = XOpenDisplay(NULL); + if (dpy) { + kc = (unsigned int)XKeysymToKeycode(dpy, (KeySym)keysym); + XCloseDisplay(dpy); + } + return kc; +} + +int linux_grab_init(int notify_fd) +{ + const xcb_setup_t *setup; + xcb_screen_iterator_t iter; + + s_notify_fd = notify_fd; + memset(s_shortcuts, 0, sizeof(s_shortcuts)); + s_shortcut_count = 0; + + s_conn = xcb_connect(NULL, NULL); + if (!s_conn || xcb_connection_has_error(s_conn)) return 0; + + setup = xcb_get_setup(s_conn); + iter = xcb_setup_roots_iterator(setup); + s_root = iter.data->root; + + s_running = 1; + pthread_create(&s_thread, NULL, event_thread, NULL); + return 1; +} + +void linux_grab_register(unsigned int keycode, uint16_t mods, uint8_t event_id) +{ + int i; + if (!s_conn || keycode == 0 || event_id == 0) return; + + pthread_mutex_lock(&s_mutex); + + /* 如果该 event_id 已存在,先解除旧的注册 */ + for (i = 0; i < s_shortcut_count; i++) { + if (s_shortcuts[i].event_id == event_id) { + do_ungrab_key(s_shortcuts[i].keycode, s_shortcuts[i].mods); + /* 从数组中移除该槽位 */ + s_shortcuts[i] = s_shortcuts[s_shortcut_count - 1]; + s_shortcut_count--; + break; + } + } + + /* 注册新快捷键 */ + if (s_shortcut_count < LINUX_GRAB_MAX_SHORTCUTS) { + s_shortcuts[s_shortcut_count].keycode = (uint8_t)keycode; + s_shortcuts[s_shortcut_count].mods = mods; + s_shortcuts[s_shortcut_count].event_id = event_id; + s_shortcut_count++; + do_grab_key((uint8_t)keycode, mods); + xcb_flush(s_conn); + } + + pthread_mutex_unlock(&s_mutex); +} + +void linux_grab_ungrab_all(void) +{ + int i; + if (!s_conn) return; + + pthread_mutex_lock(&s_mutex); + for (i = 0; i < s_shortcut_count; i++) { + if (s_shortcuts[i].event_id != 0) { + do_ungrab_key(s_shortcuts[i].keycode, s_shortcuts[i].mods); + } + } + memset(s_shortcuts, 0, sizeof(s_shortcuts)); + s_shortcut_count = 0; + xcb_flush(s_conn); + pthread_mutex_unlock(&s_mutex); +} + +void linux_grab_cleanup(void) +{ + s_running = 0; + if (s_conn) { + xcb_disconnect(s_conn); /* 使 xcb_wait_for_event 返回 NULL,线程自然退出 */ + s_conn = NULL; + } + pthread_join(s_thread, NULL); + pthread_mutex_destroy(&s_mutex); +} diff --git a/src/shortcut_key/linux_grab.h b/src/shortcut_key/linux_grab.h new file mode 100644 index 0000000..3d7f484 --- /dev/null +++ b/src/shortcut_key/linux_grab.h @@ -0,0 +1,68 @@ +#ifndef LINUX_GRAB_H +#define LINUX_GRAB_H + +/* + * linux_grab — Linux 全局快捷键抓取(XCB + 线程 + pipe) + * + * 建立独立 XCB 连接,在后台线程监听 XCB_KEY_PRESS 事件, + * 匹配已注册的快捷键后通过 pipe 写入 1 字节事件 ID, + * 主线程用 QSocketNotifier 读取并分发信号。 + * + * 接口与 win_grab.h 对称,便于 globalshortcut.cpp 统一管理。 + * + * 事件 ID: + * 1 = 截图 + * 2 = 截图并保存 + * 3 = 贴图到桌面 + * 4 = 显示/隐藏窗口 + */ + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +/* 最多支持注册的快捷键数量(与 win_grab 保持一致) */ +#define LINUX_GRAB_MAX_SHORTCUTS 8 + +/* 事件 ID */ +#define LINUX_GRAB_EVENT_SCREENSHOT 1 +#define LINUX_GRAB_EVENT_SCREENSHOT_SAVE 2 +#define LINUX_GRAB_EVENT_STICKY 3 +#define LINUX_GRAB_EVENT_TOGGLE 4 + +/* + * 用 Xlib 查询 keysym 对应的硬件 keycode,查完立即关闭 Display + */ +unsigned int linux_grab_keysym_to_keycode(unsigned long keysym); + +/* + * 初始化:建立独立 XCB 连接,启动监听线程 + * notify_fd : 可写端 pipe fd,有事件时写入 1 字节事件 ID + * 返回 1 成功,0 失败 + */ +int linux_grab_init(int notify_fd); + +/* + * 注册快捷键:keycode + mods 组合 → 触发 event_id + * 必须在 linux_grab_init() 之后调用 + * 同一 event_id 重复注册会先解除旧的再注册新的 + */ +void linux_grab_register(unsigned int keycode, uint16_t mods, uint8_t event_id); + +/* + * 解除所有已注册的快捷键(不停止监听线程) + */ +void linux_grab_ungrab_all(void); + +/* + * 停止监听线程并释放资源 + */ +void linux_grab_cleanup(void); + +#ifdef __cplusplus +} +#endif + +#endif /* LINUX_GRAB_H */ diff --git a/src/shortcut_key/win_grab.c b/src/shortcut_key/win_grab.c new file mode 100644 index 0000000..80051d6 --- /dev/null +++ b/src/shortcut_key/win_grab.c @@ -0,0 +1,104 @@ +/* + * win_grab.c — Windows 全局快捷键注册/注销(纯 C) + * + * 使用 RegisterHotKey / UnregisterHotKey 在当前线程(主线程)注册热键。 + * WM_HOTKEY 消息由 Qt 主消息循环接收,GlobalShortcut 通过 + * QAbstractNativeEventFilter::nativeEventFilter 拦截并分发。 + * + * 注意:RegisterHotKey 必须在接收 WM_HOTKEY 的同一线程调用, + * 因此所有操作均在主线程完成,无需额外线程或 pipe。 + */ + +#ifdef _WIN32 + +#define WIN32_LEAN_AND_MEAN +#include +#include "win_grab.h" +#include + +/* ── 快捷键注册表 ─────────────────────────────────────── */ +typedef struct { + unsigned int vk; /* 虚拟键码,0 表示该槽位空闲 */ + unsigned int mods; /* MOD_ALT | MOD_CONTROL | MOD_SHIFT | MOD_WIN */ + uint8_t event_id; /* 对应动作 ID (1~4) */ +} WinShortcutEntry; + +static WinShortcutEntry s_shortcuts[WIN_GRAB_MAX_SHORTCUTS]; +static int s_count = 0; + +/* ── 公共接口实现 ─────────────────────────────────────── */ + +int win_grab_init(void) +{ + memset(s_shortcuts, 0, sizeof(s_shortcuts)); + s_count = 0; + return 1; +} + +void win_grab_register(unsigned int vk, unsigned int mods, uint8_t event_id) +{ + int i; + if (vk == 0 || event_id == 0) return; + + /* 如果该 event_id 已存在,先注销旧的 */ + for (i = 0; i < s_count; i++) { + if (s_shortcuts[i].event_id == event_id) { + UnregisterHotKey(NULL, (int)event_id); + /* 从数组中移除 */ + s_shortcuts[i] = s_shortcuts[s_count - 1]; + memset(&s_shortcuts[s_count - 1], 0, sizeof(WinShortcutEntry)); + s_count--; + break; + } + } + + /* 注册新快捷键,使用 event_id 作为 hotkey id(1~4,不与系统冲突) */ + /* MOD_NOREPEAT 避免按住时重复触发(Windows 7+ 支持) */ + if (s_count < WIN_GRAB_MAX_SHORTCUTS) { + if (RegisterHotKey(NULL, (int)event_id, mods | MOD_NOREPEAT, vk)) { + s_shortcuts[s_count].vk = vk; + s_shortcuts[s_count].mods = mods; + s_shortcuts[s_count].event_id = event_id; + s_count++; + } + } +} + +void win_grab_ungrab_all(void) +{ + int i; + for (i = 0; i < s_count; i++) { + if (s_shortcuts[i].event_id != 0) { + UnregisterHotKey(NULL, (int)s_shortcuts[i].event_id); + } + } + memset(s_shortcuts, 0, sizeof(s_shortcuts)); + s_count = 0; +} + +void win_grab_cleanup(void) +{ + win_grab_ungrab_all(); +} + +unsigned int win_grab_get_vk(uint8_t event_id) +{ + int i; + for (i = 0; i < s_count; i++) { + if (s_shortcuts[i].event_id == event_id) + return s_shortcuts[i].vk; + } + return 0; +} + +unsigned int win_grab_get_mods(uint8_t event_id) +{ + int i; + for (i = 0; i < s_count; i++) { + if (s_shortcuts[i].event_id == event_id) + return s_shortcuts[i].mods; + } + return 0; +} + +#endif /* _WIN32 */ diff --git a/src/shortcut_key/win_grab.h b/src/shortcut_key/win_grab.h new file mode 100644 index 0000000..7737042 --- /dev/null +++ b/src/shortcut_key/win_grab.h @@ -0,0 +1,71 @@ +#ifndef WIN_GRAB_H +#define WIN_GRAB_H + +/* + * win_grab — Windows 全局快捷键抓取 + * + * 使用 Windows RegisterHotKey / UnregisterHotKey API 注册快捷键, + * 通过 Qt 的 QAbstractNativeEventFilter 在主线程消息循环中接收 WM_HOTKEY, + * 触发回调通知上层(与 x11_grab 的 pipe 通知对称)。 + * + * 事件 ID 与 x11_grab.h 保持一致: + * 1 = 截图 + * 2 = 截图并保存 + * 3 = 贴图到桌面 + * 4 = 显示/隐藏窗口 + */ + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +/* 最多支持注册的快捷键数量(与 x11_grab 保持一致) */ +#define WIN_GRAB_MAX_SHORTCUTS 8 + +/* 事件 ID(与 x11_grab.h 中的宏值相同) */ +#define WIN_GRAB_EVENT_SCREENSHOT 1 +#define WIN_GRAB_EVENT_SCREENSHOT_SAVE 2 +#define WIN_GRAB_EVENT_STICKY 3 +#define WIN_GRAB_EVENT_TOGGLE 4 + +/* + * 初始化(Windows 平台为空操作,只做状态清零,保持接口对称) + * 返回 1 成功 + */ +int win_grab_init(void); + +/* + * 注册快捷键 + * vk : Windows 虚拟键码 (VK_*) + * mods : MOD_ALT | MOD_CONTROL | MOD_SHIFT | MOD_WIN 组合 + * event_id: 触发时的事件 ID(1~4) + */ +void win_grab_register(unsigned int vk, unsigned int mods, uint8_t event_id); + +/* + * 解除所有已注册快捷键 + */ +void win_grab_ungrab_all(void); + +/* + * 释放所有资源(对称接口) + */ +void win_grab_cleanup(void); + +/* + * 查询某个事件 ID 对应的虚拟键码(0 表示未注册) + */ +unsigned int win_grab_get_vk(uint8_t event_id); + +/* + * 查询某个事件 ID 对应的修饰键掩码 + */ +unsigned int win_grab_get_mods(uint8_t event_id); + +#ifdef __cplusplus +} +#endif + +#endif /* WIN_GRAB_H */ diff --git a/src/shortcut_key/x11_grab.c b/src/shortcut_key/x11_grab.c new file mode 100644 index 0000000..cfb27dd --- /dev/null +++ b/src/shortcut_key/x11_grab.c @@ -0,0 +1,198 @@ +/* 纯 C 翻译单元:独立 XCB 连接 + 线程监听按键,通过 pipe 通知 Qt */ +#include "x11_grab.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +/* ── 动态快捷键表 ─────────────────────────────────────── */ +typedef struct { + uint8_t keycode; + uint16_t mods; + uint8_t event_id; /* 0 表示该槽位空闲 */ +} ShortcutEntry; + +static ShortcutEntry s_shortcuts[X11_GRAB_MAX_SHORTCUTS]; +static int s_shortcut_count = 0; +static pthread_mutex_t s_mutex = PTHREAD_MUTEX_INITIALIZER; + +/* ── XCB 全局状态 ─────────────────────────────────────── */ +static xcb_connection_t *s_conn = NULL; +static xcb_window_t s_root = 0; +static pthread_t s_thread; +static int s_notify_fd = -1; +static volatile int s_running = 0; + +/* NumLock / CapsLock 修饰键组合,每个快捷键注册 4 次 */ +static const uint16_t kExtraMods[] = { + 0, + XCB_MOD_MASK_2, /* NumLock */ + XCB_MOD_MASK_LOCK, /* CapsLock */ + XCB_MOD_MASK_2 | XCB_MOD_MASK_LOCK +}; +#define EXTRA_MODS_COUNT 4 + +/* ── 内部辅助函数 ─────────────────────────────────────── */ + +static void do_grab_key(uint8_t keycode, uint16_t mods) +{ + unsigned int i; + for (i = 0; i < EXTRA_MODS_COUNT; i++) { + xcb_grab_key(s_conn, + 0, /* owner_events = 0 */ + s_root, + (uint16_t)(mods | kExtraMods[i]), + keycode, + XCB_GRAB_MODE_ASYNC, + XCB_GRAB_MODE_ASYNC); + } +} + +static void do_ungrab_key(uint8_t keycode, uint16_t mods) +{ + unsigned int i; + for (i = 0; i < EXTRA_MODS_COUNT; i++) { + xcb_ungrab_key(s_conn, + keycode, + s_root, + (uint16_t)(mods | kExtraMods[i])); + } +} + +/* ── 监听线程 ─────────────────────────────────────────── */ + +static void *event_thread(void *arg) +{ + (void)arg; + while (s_running) { + xcb_generic_event_t *ev = xcb_wait_for_event(s_conn); + if (!ev) break; /* 连接断开,线程退出 */ + + if ((ev->response_type & ~0x80) == XCB_KEY_PRESS) { + xcb_key_press_event_t *kev = (xcb_key_press_event_t *)ev; + uint8_t kc = kev->detail; + /* 屏蔽 NumLock / CapsLock,避免影响匹配 */ + uint16_t mods = (uint16_t)(kev->state & + ~(uint16_t)(XCB_MOD_MASK_2 | XCB_MOD_MASK_LOCK)); + + uint8_t evt = 0; + int i; + pthread_mutex_lock(&s_mutex); + for (i = 0; i < s_shortcut_count; i++) { + if (s_shortcuts[i].event_id != 0 && + s_shortcuts[i].keycode == kc && + s_shortcuts[i].mods == mods) { + evt = s_shortcuts[i].event_id; + break; + } + } + pthread_mutex_unlock(&s_mutex); + + if (evt != 0) { + write(s_notify_fd, &evt, 1); + } + } + free(ev); + } + return NULL; +} + +/* ── 公共接口实现 ─────────────────────────────────────── */ + +unsigned int x11_grab_keysym_to_keycode(unsigned long keysym) +{ + unsigned int kc = 0; + Display *dpy = XOpenDisplay(NULL); + if (dpy) { + kc = (unsigned int)XKeysymToKeycode(dpy, (KeySym)keysym); + XCloseDisplay(dpy); + } + return kc; +} + +int x11_grab_init(int notify_fd) +{ + const xcb_setup_t *setup; + xcb_screen_iterator_t iter; + + s_notify_fd = notify_fd; + memset(s_shortcuts, 0, sizeof(s_shortcuts)); + s_shortcut_count = 0; + + s_conn = xcb_connect(NULL, NULL); + if (!s_conn || xcb_connection_has_error(s_conn)) return 0; + + setup = xcb_get_setup(s_conn); + iter = xcb_setup_roots_iterator(setup); + s_root = iter.data->root; + + s_running = 1; + pthread_create(&s_thread, NULL, event_thread, NULL); + return 1; +} + +void x11_grab_register(unsigned int keycode, uint16_t mods, uint8_t event_id) +{ + int i; + if (!s_conn || keycode == 0 || event_id == 0) return; + + pthread_mutex_lock(&s_mutex); + + /* 如果该 event_id 已存在,先解除旧的注册 */ + for (i = 0; i < s_shortcut_count; i++) { + if (s_shortcuts[i].event_id == event_id) { + do_ungrab_key(s_shortcuts[i].keycode, s_shortcuts[i].mods); + /* 从数组中移除该槽位 */ + s_shortcuts[i] = s_shortcuts[s_shortcut_count - 1]; + s_shortcut_count--; + break; + } + } + + /* 注册新快捷键 */ + if (s_shortcut_count < X11_GRAB_MAX_SHORTCUTS) { + s_shortcuts[s_shortcut_count].keycode = (uint8_t)keycode; + s_shortcuts[s_shortcut_count].mods = mods; + s_shortcuts[s_shortcut_count].event_id = event_id; + s_shortcut_count++; + do_grab_key((uint8_t)keycode, mods); + xcb_flush(s_conn); + } + + pthread_mutex_unlock(&s_mutex); +} + +void x11_grab_ungrab_all(void) +{ + int i; + if (!s_conn) return; + + pthread_mutex_lock(&s_mutex); + for (i = 0; i < s_shortcut_count; i++) { + if (s_shortcuts[i].event_id != 0) { + do_ungrab_key(s_shortcuts[i].keycode, s_shortcuts[i].mods); + } + } + memset(s_shortcuts, 0, sizeof(s_shortcuts)); + s_shortcut_count = 0; + xcb_flush(s_conn); + pthread_mutex_unlock(&s_mutex); +} + +void x11_grab_cleanup(void) +{ + s_running = 0; + if (s_conn) { + xcb_disconnect(s_conn); /* 使 xcb_wait_for_event 返回 NULL,线程自然退出 */ + s_conn = NULL; + } + pthread_join(s_thread, NULL); + pthread_mutex_destroy(&s_mutex); +} diff --git a/src/shortcut_key/x11_grab.h b/src/shortcut_key/x11_grab.h new file mode 100644 index 0000000..ede2d8c --- /dev/null +++ b/src/shortcut_key/x11_grab.h @@ -0,0 +1,42 @@ +#ifndef X11_GRAB_H +#define X11_GRAB_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +/* 事件 ID:通过 pipe 写给 Qt 主线程,对应 4 个功能动作 */ +#define X11_GRAB_EVENT_SCREENSHOT 1 /* 截图 */ +#define X11_GRAB_EVENT_SCREENSHOT_SAVE 2 /* 截图并保存 */ +#define X11_GRAB_EVENT_STICKY 3 /* 贴图到桌面 */ +#define X11_GRAB_EVENT_TOGGLE 4 /* 显示/隐藏窗口 */ + +/* 最多支持注册的快捷键数量 */ +#define X11_GRAB_MAX_SHORTCUTS 8 + +/* 用 Xlib 查询 keysym 对应的硬件 keycode,查完立即关闭 Display */ +unsigned int x11_grab_keysym_to_keycode(unsigned long keysym); + +/* 初始化:建立独立 XCB 连接,启动监听线程 + * notify_fd : 可写端 pipe fd,有事件时写入 1 字节事件 ID + * 返回 1 成功,0 失败 */ +int x11_grab_init(int notify_fd); + +/* 注册一个快捷键:keycode + mods 组合 → 触发 event_id + * 必须在 x11_grab_init() 之后调用 + * 同一 event_id 重复注册会先解除旧的再注册新的 */ +void x11_grab_register(unsigned int keycode, uint16_t mods, uint8_t event_id); + +/* 解除所有已注册的快捷键(不停止监听线程) */ +void x11_grab_ungrab_all(void); + +/* 停止监听线程并释放资源 */ +void x11_grab_cleanup(void); + +#ifdef __cplusplus +} +#endif + +#endif /* X11_GRAB_H */ diff --git a/src/singleton.h b/src/singleton.h new file mode 100644 index 0000000..63b35d8 --- /dev/null +++ b/src/singleton.h @@ -0,0 +1,23 @@ +#ifndef SINGLETON_H +#define SINGLETON_H + + +template +class Singleton +{ +public: + static T& instant(){ + static T s; + return s; + } + + Singleton(T&&) = delete; + Singleton(const T&) = delete; + void operator= (const T&) = delete; + +protected: + Singleton() = default; + virtual ~Singleton() = default; +}; + +#endif // SINGLETON_H diff --git a/src/sticky_image_provider_proxy.cpp b/src/sticky_image_provider_proxy.cpp new file mode 100644 index 0000000..5e1a0cf --- /dev/null +++ b/src/sticky_image_provider_proxy.cpp @@ -0,0 +1,25 @@ +#include "sticky_image_provider_proxy.h" + +StickyImageProviderProxy::StickyImageProviderProxy(StickyImageStore &store) + : QQuickImageProvider(QQuickImageProvider::Image) + , m_store(store) +{ +} + +QImage StickyImageProviderProxy::requestImage(const QString &id, QSize *size, const QSize &requestedSize) +{ + Q_UNUSED(requestedSize) + + QString cleanId = id; + const int queryIndex = cleanId.indexOf('?'); + if (queryIndex >= 0) { + cleanId = cleanId.left(queryIndex); + } + + QImage image = m_store.getImage(cleanId); + + if (size) { + *size = image.size(); + } + return image; +} diff --git a/src/sticky_image_provider_proxy.h b/src/sticky_image_provider_proxy.h new file mode 100644 index 0000000..0d4f929 --- /dev/null +++ b/src/sticky_image_provider_proxy.h @@ -0,0 +1,24 @@ +#ifndef STICKY_IMAGE_PROVIDER_PROXY_H +#define STICKY_IMAGE_PROVIDER_PROXY_H + +#include +#include "sticky_image_store.h" + +// StickyImageProviderProxy +// ------------------------ +// 纯粹的 QQuickImageProvider 实现,不继承 QObject。 +// 生命周期由 QQmlApplicationEngine 通过 addImageProvider() 接管并负责释放。 +// 所有实际数据操作委托给 StickyImageStore,自身不持有任何图片数据。 + +class StickyImageProviderProxy : public QQuickImageProvider +{ +public: + explicit StickyImageProviderProxy(StickyImageStore &store); + + QImage requestImage(const QString &id, QSize *size, const QSize &requestedSize) override; + +private: + StickyImageStore &m_store; // 引用,不持有所有权 +}; + +#endif // STICKY_IMAGE_PROVIDER_PROXY_H diff --git a/src/sticky_image_store.cpp b/src/sticky_image_store.cpp new file mode 100644 index 0000000..b29f9c9 --- /dev/null +++ b/src/sticky_image_store.cpp @@ -0,0 +1,235 @@ +#include "sticky_image_store.h" + +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_LINUX +#include +#include +#endif +#include + +StickyImageStore::StickyImageStore(QObject *parent) + : QObject(parent) +{ +} + +QString StickyImageStore::storeImage(const QImage &image) +{ + if (image.isNull()) { + return QString(); + } + +#ifdef STICKY_MEM_DEBUG + logMemoryUsage(QStringLiteral("store-before")); +#endif + + const QString id = QUuid::createUuid().toString(QUuid::WithoutBraces); + { + QMutexLocker locker(&m_mutex); + m_images.insert(id, image); + } + +#ifdef STICKY_MEM_DEBUG + logMemoryUsage(QStringLiteral("store-after")); +#endif + + return QStringLiteral("image://sticky/") + id; +} + +QImage StickyImageStore::getImage(const QString &id) const +{ + QMutexLocker locker(&m_mutex); + return m_images.value(id); +} + +QImage StickyImageStore::getImageByUrl(const QString &imageUrl) const +{ + const QString id = extractId(imageUrl); + if (id.isEmpty()) { + return {}; + } + return getImage(id); +} + +void StickyImageStore::releaseImage(const QString &imageUrl) +{ +#ifdef STICKY_MEM_DEBUG + logMemoryUsage(QStringLiteral("release-before")); +#endif + + const QString id = extractId(imageUrl); + if (id.isEmpty()) { + qWarning() << "[StickyMemory] release-skip invalid url:" << imageUrl; + return; + } + + { + QMutexLocker locker(&m_mutex); + m_images.remove(id); + } + +#if defined(STICKY_MEM_DEBUG) && defined(Q_OS_LINUX) + malloc_trim(0); +#endif + +#ifdef STICKY_MEM_DEBUG + logMemoryUsage(QStringLiteral("release-after")); +#endif +} + +bool StickyImageStore::saveImage(const QString &imageUrl, const QUrl &targetUrl) +{ + if (targetUrl.isEmpty() || !targetUrl.isLocalFile()) { + return false; + } + + const QString id = extractId(imageUrl); + if (id.isEmpty()) { + return false; + } + + QImage image; + { + QMutexLocker locker(&m_mutex); + image = m_images.value(id); + } + + if (image.isNull()) { + return false; + } + + return image.save(targetUrl.toLocalFile()); +} + +bool StickyImageStore::replaceImage(const QString &imageUrl, const QImage &image) +{ + if (image.isNull()) { + return false; + } + + const QString id = extractId(imageUrl); + if (id.isEmpty()) { + return false; + } + + QMutexLocker locker(&m_mutex); + if (!m_images.contains(id)) { + return false; + } + m_images[id] = image; + return true; +} + +bool StickyImageStore::copyImageToClipboard(const QString &imageUrl) +{ + const QString id = extractId(imageUrl); + if (id.isEmpty()) { + return false; + } + + QImage image; + { + QMutexLocker locker(&m_mutex); + image = m_images.value(id); + } + + if (image.isNull()) { + return false; + } + + QClipboard *clipboard = QGuiApplication::clipboard(); + if (!clipboard) { + return false; + } + + clipboard->setImage(image, QClipboard::Clipboard); +#ifdef Q_OS_LINUX + clipboard->setImage(image, QClipboard::Selection); +#endif + return true; +} + +QString StickyImageStore::extractId(const QString &imageUrl) const +{ + static const QString prefix = QStringLiteral("image://sticky/"); + if (!imageUrl.startsWith(prefix)) { + return QString(); + } + + QString id = imageUrl.mid(prefix.size()); + const int queryIndex = id.indexOf('?'); + if (queryIndex >= 0) { + id = id.left(queryIndex); + } + return id; +} + +qint64 StickyImageStore::processRssKb() const +{ +#ifdef Q_OS_LINUX + QFile statusFile(QStringLiteral("/proc/self/status")); + if (!statusFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + qWarning() << "[StickyMemory] open /proc/self/status failed:" << statusFile.errorString(); + } else { + QTextStream in(&statusFile); + while (!in.atEnd()) { + const QString line = in.readLine(); + if (!line.contains(QStringLiteral("VmRSS:"))) { + continue; + } + + const QStringList parts = line.simplified().split(' '); + if (parts.size() >= 2) { + bool ok = false; + const qint64 value = parts.at(1).toLongLong(&ok); + if (ok) { + return value; + } + } + qWarning() << "[StickyMemory] parse VmRSS failed, line:" << line; + break; + } + } + + QFile statmFile(QStringLiteral("/proc/self/statm")); + if (statmFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + const QByteArray raw = statmFile.readAll().trimmed(); + const QList fields = raw.split(' '); + if (fields.size() >= 2) { + bool ok = false; + const qint64 residentPages = fields.at(1).toLongLong(&ok); + if (ok) { + const long pageSize = sysconf(_SC_PAGESIZE); + if (pageSize > 0) { + return (residentPages * pageSize) / 1024; + } + } + } + qWarning() << "[StickyMemory] parse /proc/self/statm failed, raw:" << raw; + } else { + qWarning() << "[StickyMemory] open /proc/self/statm failed:" << statmFile.errorString(); + } + + return -1; +#else + return -1; +#endif +} + +int StickyImageStore::imageCount() const +{ + QMutexLocker locker(&m_mutex); + return m_images.size(); +} + +void StickyImageStore::logMemoryUsage(const QString &tag) const +{ + qWarning().nospace() << "[StickyMemory] " << tag + << " rss_kb=" << processRssKb() + << " image_count=" << imageCount(); +} diff --git a/src/sticky_image_store.h b/src/sticky_image_store.h new file mode 100644 index 0000000..437c33b --- /dev/null +++ b/src/sticky_image_store.h @@ -0,0 +1,47 @@ +#ifndef STICKY_IMAGE_STORE_H +#define STICKY_IMAGE_STORE_H + +#include +#include +#include +#include +#include +#include + +// StickyImageStore +// ---------------- +// 纯业务类(QObject),负责贴图的存储、释放、保存、复制到剪贴板等操作。 +// 不继承 QQuickImageProvider,生命周期完全由调用方控制,不受 engine 影响。 +// 图片数据通过 storeImage() 写入,由 StickyImageProviderProxy 的 requestImage() 读取。 + +class StickyImageStore : public QObject +{ + Q_OBJECT + +public: + explicit StickyImageStore(QObject *parent = nullptr); + + // 存储图片,返回可供 QML Image.source 使用的 URL 字符串 + // 格式:"image://sticky/" + QString storeImage(const QImage &image); + + // 供 StickyImageProviderProxy 调用,线程安全 + QImage getImage(const QString &id) const; + QImage getImageByUrl(const QString &imageUrl) const; + + Q_INVOKABLE void releaseImage(const QString &imageUrl); + Q_INVOKABLE bool saveImage(const QString &imageUrl, const QUrl &targetUrl); + Q_INVOKABLE bool replaceImage(const QString &imageUrl, const QImage &image); + Q_INVOKABLE bool copyImageToClipboard(const QString &imageUrl); + Q_INVOKABLE qint64 processRssKb() const; + Q_INVOKABLE int imageCount() const; + Q_INVOKABLE void logMemoryUsage(const QString &tag) const; + +private: + QString extractId(const QString &imageUrl) const; + + QHash m_images; + mutable QMutex m_mutex; +}; + +#endif // STICKY_IMAGE_STORE_H diff --git a/src/tray_icon_helper.cpp b/src/tray_icon_helper.cpp new file mode 100644 index 0000000..d90428d --- /dev/null +++ b/src/tray_icon_helper.cpp @@ -0,0 +1,82 @@ +#include "tray_icon_helper.h" + +TrayIconHelper::TrayIconHelper(QObject *parent) : QObject(parent) +{ + m_trayIcon = new QSystemTrayIcon(this); + m_trayMenu = new QMenu(); + + m_screenshotAction = new QAction(QObject::tr("截图"), this); + connect(m_screenshotAction, &QAction::triggered, this, &TrayIconHelper::screenshotTriggered); + + m_showSettingAction = new QAction(QObject::tr("设置"), this); + connect(m_showSettingAction, &QAction::triggered, this, &TrayIconHelper::showSettingTriggered); + + m_showAboutAction = new QAction(QObject::tr("关于"), this); + connect(m_showAboutAction, &QAction::triggered, this, &TrayIconHelper::showAboutTriggered); + + m_exitAction = new QAction(QObject::tr("退出"), this); + connect(m_exitAction, &QAction::triggered, this, &TrayIconHelper::exitTriggered); + + m_trayMenu->addAction(m_screenshotAction); + m_trayMenu->addAction(m_showSettingAction); + m_trayMenu->addAction(m_showAboutAction); + m_trayMenu->addSeparator(); + m_trayMenu->addAction(m_exitAction); + + m_trayIcon->setContextMenu(m_trayMenu); + connect(m_trayIcon, &QSystemTrayIcon::activated, this, &TrayIconHelper::onTrayIconActivated); +} + +TrayIconHelper::~TrayIconHelper() +{ + delete m_exitAction; + delete m_showAboutAction; + delete m_showSettingAction; + delete m_screenshotAction; + delete m_trayMenu; + delete m_trayIcon; +} + +void TrayIconHelper::setIcon(const QString &iconPath) +{ + m_trayIcon->setIcon(QIcon(iconPath)); +} + +void TrayIconHelper::show() +{ + if (m_trayIcon->icon().isNull()) { + QPixmap pixmap(24, 24); + pixmap.fill(Qt::red); + m_trayIcon->setIcon(QIcon(pixmap)); + } + m_trayIcon->show(); +} + +void TrayIconHelper::hide() +{ + m_trayIcon->hide(); +} + +void TrayIconHelper::showMessage(const QString &title, const QString &msg) +{ + m_trayIcon->showMessage(title, msg, QSystemTrayIcon::Information, 2000); +} + +void TrayIconHelper::onTrayIconActivated(QSystemTrayIcon::ActivationReason reason) +{ + int clickType = 0; + switch (reason) { + case QSystemTrayIcon::Trigger: + clickType = 0; + break; + case QSystemTrayIcon::Context: + clickType = 1; + break; + case QSystemTrayIcon::DoubleClick: + clickType = 2; + break; + default: + return; + } + emit trayClicked(clickType); +} diff --git a/src/tray_icon_helper.h b/src/tray_icon_helper.h new file mode 100644 index 0000000..b8a54be --- /dev/null +++ b/src/tray_icon_helper.h @@ -0,0 +1,42 @@ +#ifndef TRAYICONHELPER_H +#define TRAYICONHELPER_H + +#include +#include +#include +#include +#include +#include + +class TrayIconHelper : public QObject +{ + Q_OBJECT +public: + explicit TrayIconHelper(QObject *parent = nullptr); + ~TrayIconHelper(); + + Q_INVOKABLE void setIcon(const QString &iconPath); + Q_INVOKABLE void show(); + Q_INVOKABLE void hide(); + Q_INVOKABLE void showMessage(const QString &title, const QString &msg); + +signals: + void trayClicked(int clickType); + void screenshotTriggered(); + void showSettingTriggered(); + void showAboutTriggered(); + void exitTriggered(); + +private slots: + void onTrayIconActivated(QSystemTrayIcon::ActivationReason reason); + +private: + QSystemTrayIcon *m_trayIcon; + QMenu *m_trayMenu; + QAction *m_screenshotAction; + QAction *m_showSettingAction; + QAction *m_showAboutAction; + QAction *m_exitAction; +}; + +#endif // TRAYICONHELPER_H diff --git a/src/viewmodel/ai_view_model.cpp b/src/viewmodel/ai_view_model.cpp new file mode 100644 index 0000000..84856dd --- /dev/null +++ b/src/viewmodel/ai_view_model.cpp @@ -0,0 +1,190 @@ +#include "ai_view_model.h" +#include "ai_call/qwen_ai_call.h" +#include "ai_call/seedream_ai_call.h" +#include "ai_call/ai_type.h" +#include "sticky_image_store.h" + +#include +#include +#include +#include +#include +#include + +AIViewModel::AIViewModel(AppSettings &settings, + StickyImageStore &store, + QObject *parent) + : QObject(parent) + , m_settings(settings) + , m_store(store) +{ + rebuildAiCall(); +} + +QString AIViewModel::apiKey() const { return m_settings.apiKey(); } +int AIViewModel::selectedModel() const { return m_settings.selectedModel(); } +bool AIViewModel::isLoading() const { return m_loading; } + +void AIViewModel::setLoading(bool v) +{ + if (m_loading == v) return; + m_loading = v; + emit isLoadingChanged(v); +} + +// ── 工厂:根据 selectedModel 创建/重建 AI 调用实例 ────────── +void AIViewModel::rebuildAiCall() +{ + if (m_aiCall) { + m_aiCall->cancelRequest(); + m_aiCall->deleteLater(); + m_aiCall = nullptr; + } + + const QString key = m_settings.apiKey(); + + switch (m_settings.selectedModel()) { + case SEEDREAMAI: { + m_aiCall = new SeedreamAiCall(key, this); + qInfo() << "[AIViewModel] 使用模型:火山引擎 Seedream"; + break; + } + case QWENAI: + default: { + m_aiCall = new QwenAICall(key, this); + qInfo() << "[AIViewModel] 使用模型:阿里云百炼 Qwen"; + break; + } + } + + // requestSuccess 只表示 AI 接口返回了图片 URL, + // 真正完成还需要下载该图片(在 sendPrompt 的 lambda 里处理) + connect(m_aiCall, &AICallBase::requestFailed, this, + [this](AIErrorType errorType, const QString &errorMsg) { + QString prefix; + switch (errorType) { + case AIErrorType::NetworkError: prefix = "网络错误:"; break; + case AIErrorType::ApiError: prefix = "API 错误:"; break; + case AIErrorType::TimeoutError: prefix = "超时错误:"; break; + case AIErrorType::ParamError: prefix = "参数错误:"; break; + default: prefix = "未知错误:"; break; + } + const QString msg = prefix + errorMsg; + qWarning() << "[AIViewModel]" << msg; + setLoading(false); + emit signalRequestFailed(msg); + }); +} + +// ── setApiKey ──────────────────────────────────────────────── +bool AIViewModel::setApiKey(const QString &key) +{ + if (m_settings.apiKey() == key) return true; + m_settings.setApiKey(key); + rebuildAiCall(); + emit apiKeyChanged(key); + return true; +} + +// ── setSelectedModel ───────────────────────────────────────── +void AIViewModel::setSelectedModel(int index) +{ + if (m_settings.selectedModel() == index) return; + m_settings.setSelectedModel(index); + rebuildAiCall(); + emit selectedModelChanged(index); +} + +// ── sendPrompt ─────────────────────────────────────────────── +// 流程: +// 1. 从 StickyImageStore 取出原图,编码为 base64 传给 AI +// 2. AI 返回新图片的网络 URL(requestSuccess) +// 3. 用内置 QNetworkAccessManager 下载该图片 +// 4. 存入 StickyImageStore,emit signalRequestComplete(新 image://sticky/... URL) +void AIViewModel::sendPrompt(const QString &prompt, const QString &imageUrl) +{ + if (m_loading) { + qWarning() << "[AIViewModel] 已有请求进行中,忽略本次调用"; + return; + } + if (prompt.trimmed().isEmpty()) { + emit signalRequestFailed("提示词不能为空"); + return; + } + + // 从 Store 取原图并编码为 base64(API 接受 data URI) + QString imgParam; + { + const QString prefix = QStringLiteral("image://sticky/"); + QString id = imageUrl.startsWith(prefix) + ? imageUrl.mid(prefix.size()) + : QString(); + if (!id.isEmpty()) { + QImage img = m_store.getImage(id); + if (!img.isNull()) { + QByteArray ba; + QBuffer buf(&ba); + buf.open(QIODevice::WriteOnly); + img.save(&buf, "PNG"); + imgParam = QStringLiteral("data:image/png;base64,") + + QString::fromLatin1(ba.toBase64()); + } + } + } + + if (imgParam.isEmpty()) { + imgParam = imageUrl; + qWarning() << "[AIViewModel] 未能从 Store 获取原图,回退使用 imageUrl"; + } + + setLoading(true); + + // 断开上次 requestSuccess 的连接(避免重建前残留信号叠加) + disconnect(m_aiCall, &AICallBase::requestSuccess, this, nullptr); + + // 捕获原图 URL,用于 signalRequestComplete 让各窗口识别是否是自己的请求 + const QString originalUrl = imageUrl; + + connect(m_aiCall, &AICallBase::requestSuccess, this, + [this, originalUrl](const QString &aiImageUrl) { + qDebug() << "[AIViewModel] AI 返回图片 URL:" << aiImageUrl; + + auto *dlManager = new QNetworkAccessManager(this); + QNetworkReply *reply = dlManager->get(QNetworkRequest(QUrl(aiImageUrl))); + + connect(reply, &QNetworkReply::finished, this, + [this, reply, dlManager, originalUrl]() { + reply->deleteLater(); + dlManager->deleteLater(); + setLoading(false); + + if (reply->error() != QNetworkReply::NoError) { + const QString msg = QStringLiteral("下载生成图片失败:") + reply->errorString(); + qWarning() << "[AIViewModel]" << msg; + emit signalRequestFailed(msg); + return; + } + + QImage newImage; + if (!newImage.load(reply, nullptr)) { + const QString msg = QStringLiteral("生成图片解码失败"); + qWarning() << "[AIViewModel]" << msg; + emit signalRequestFailed(msg); + return; + } + + const QString newStickyUrl = m_store.storeImage(newImage); + qDebug() << "[AIViewModel] 新贴图 URL:" << newStickyUrl; + // 同时发出原图 URL,让各贴图窗口判断是否是自己触发的请求 + emit signalRequestComplete(originalUrl, newStickyUrl); + }); + }); + + bool ok = m_aiCall->sendRequest(prompt, QJsonObject{ {"img", imgParam} }); + if (!ok) { + setLoading(false); + disconnect(m_aiCall, &AICallBase::requestSuccess, this, nullptr); + emit signalRequestFailed( + QStringLiteral("请求发起失败:") + m_aiCall->lastErrorString()); + } +} diff --git a/src/viewmodel/ai_view_model.h b/src/viewmodel/ai_view_model.h new file mode 100644 index 0000000..dcc088f --- /dev/null +++ b/src/viewmodel/ai_view_model.h @@ -0,0 +1,66 @@ +#ifndef AI_VIEW_MODEL_H +#define AI_VIEW_MODEL_H + +#include +#include "model/app_settings.h" +#include "ai_call/ai_call_base.h" + +class StickyImageStore; // 前向声明,避免循环依赖 + +// AIViewModel +// ----------- +// 负责 AI 图像识别的请求编排,以及 apiKey / selectedModel 的 +// Q_PROPERTY 暴露(数据由 AppSettings Model 持久化)。 +// +// selectedModel 枚举: +// 0 → 阿里云百炼(QwenAICall) +// 1 → 火山引擎 (SeedreamAiCall) + +class AIViewModel : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString apiKey READ apiKey NOTIFY apiKeyChanged) + Q_PROPERTY(int selectedModel READ selectedModel NOTIFY selectedModelChanged) + Q_PROPERTY(bool isLoading READ isLoading NOTIFY isLoadingChanged) + +public: + explicit AIViewModel(AppSettings &settings, + StickyImageStore &store, + QObject *parent = nullptr); + + QString apiKey() const; + int selectedModel() const; + bool isLoading() const; + + Q_INVOKABLE bool setApiKey(const QString &key); + Q_INVOKABLE void setSelectedModel(int index); + + // 发起 AI 图像编辑请求 + // prompt : 用户提示词 + // imageUrl : 当前贴图的 image://sticky/... URL(用于读取原图) + Q_INVOKABLE void sendPrompt(const QString &prompt, const QString &imageUrl); + +signals: + void apiKeyChanged(const QString &apiKey); + void selectedModelChanged(int index); + + // AI 生成完毕,oldImageUrl 是请求时的原图 URL,newImageUrl 是生成的新图 URL + // 调用方应检查 oldImageUrl 是否与自己的 imageUrl 一致再替换 + void signalRequestComplete(const QString &oldImageUrl, const QString &newImageUrl); + + // AI 请求失败,返回错误描述 + void signalRequestFailed(const QString &errorMsg); + + void isLoadingChanged(bool loading); + +private: + void rebuildAiCall(); + void setLoading(bool v); + + AppSettings &m_settings; + StickyImageStore &m_store; + AICallBase *m_aiCall = nullptr; + bool m_loading = false; +}; + +#endif // AI_VIEW_MODEL_H diff --git a/src/viewmodel/gif_record_view_model.cpp b/src/viewmodel/gif_record_view_model.cpp new file mode 100644 index 0000000..1945d79 --- /dev/null +++ b/src/viewmodel/gif_record_view_model.cpp @@ -0,0 +1,204 @@ +#include "gif_record_view_model.h" +#include "gif_encoder/gif_encoder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +GifRecordViewModel::GifRecordViewModel(AppSettings& settings, QObject* parent) + : QObject(parent) + , m_settings(settings) + , m_grabber(this) +{ + connect(&m_grabber, &GifFrameGrabber::frameCaptured, + this, &GifRecordViewModel::onFrameCaptured); + + m_elapsedTimer = new QTimer(this); + m_elapsedTimer->setInterval(1000); + connect(m_elapsedTimer, &QTimer::timeout, this, &GifRecordViewModel::onElapsedTimer); + + m_encodeWatcher = new QFutureWatcher(this); + connect(m_encodeWatcher, &QFutureWatcher::finished, + this, &GifRecordViewModel::onEncodingFinished); +} + +GifRecordViewModel::~GifRecordViewModel() +{ + m_grabber.stop(); + m_elapsedTimer->stop(); +} + +void GifRecordViewModel::startRecording(int x, int y, int w, int h) +{ + if (m_recording || m_encoding) return; + + // 标准化选区(处理负宽高) + m_captureRect = QRect( + qMin(x, x + w), qMin(y, y + h), + qAbs(w), qAbs(h) + ); + + if (m_captureRect.isEmpty()) { + emit encodingFailed(tr("录制区域无效")); + return; + } + + m_frames.clear(); + m_elapsedSecs = 0; + m_recording = true; + + emit isRecordingChanged(); + emit frameCountChanged(); + emit elapsedSecsChanged(); + + m_grabber.start(m_captureRect, kFps); + m_elapsedTimer->start(); + + qDebug() << "[GifRecord] 开始录制,区域:" << m_captureRect << ",帧率:" << kFps; +} + +void GifRecordViewModel::updateCaptureRect(int x, int y, int w, int h) +{ + if (!m_recording) return; + + QRect newRect( + qMin(x, x + w), qMin(y, y + h), + qAbs(w), qAbs(h) + ); + + if (newRect.isEmpty()) return; + + m_captureRect = newRect; + // 通知 GifFrameGrabber 立即使用新区域(下一帧起生效) + m_grabber.updateRect(newRect); + + qDebug() << "[GifRecord] 更新录制区域:" << m_captureRect; +} + +void GifRecordViewModel::stopRecording() +{ + if (!m_recording) return; + + m_grabber.stop(); + m_elapsedTimer->stop(); + m_recording = false; + emit isRecordingChanged(); + + qDebug() << "[GifRecord] 停止录制,已采集" << m_frames.size() << "帧"; + + if (m_frames.isEmpty()) { + emit encodingFailed(tr("未采集到任何帧")); + return; + } + + startEncoding(); +} + +void GifRecordViewModel::cancelRecording() +{ + m_grabber.stop(); + m_elapsedTimer->stop(); + resetState(); + qDebug() << "[GifRecord] 取消录制"; +} + +void GifRecordViewModel::onFrameCaptured(const QImage& frame) +{ + if (!m_recording) return; + + m_frames.append(frame); + emit frameCountChanged(); + + // 达到最大帧数时自动停止 + if (m_frames.size() >= kMaxFrames) { + qDebug() << "[GifRecord] 达到最大帧数,自动停止录制"; + stopRecording(); + } +} + +void GifRecordViewModel::onElapsedTimer() +{ + ++m_elapsedSecs; + emit elapsedSecsChanged(); +} + +void GifRecordViewModel::onEncodingFinished() +{ + bool success = m_encodeWatcher->result(); + m_encoding = false; + emit isEncodingChanged(); + + if (success) { + qDebug() << "[GifRecord] 编码完成:" << m_pendingSavePath; + emit encodingFinished(m_pendingSavePath); + + const QString saveDirPath = QFileInfo(m_pendingSavePath).absolutePath(); + if (!saveDirPath.isEmpty()) { + const bool opened = QDesktopServices::openUrl(QUrl::fromLocalFile(saveDirPath)); + if (!opened) { + qWarning() << "[GifRecord] Failed to open save directory:" << saveDirPath; + } + } + } else { + // 错误信息无法从子线程直接传回,通过约定字符串传递 + emit encodingFailed(tr("GIF 编码失败,请检查保存路径或磁盘空间")); + } + + m_frames.clear(); + m_pendingSavePath.clear(); +} + +void GifRecordViewModel::startEncoding() +{ + m_pendingSavePath = generateSavePath(); + m_encoding = true; + emit isEncodingChanged(); + + qDebug() << "[GifRecord] 开始编码 →" << m_pendingSavePath; + + // 捕获当前帧序列的副本(子线程持有) + QVector framesToEncode = m_frames; + QString savePath = m_pendingSavePath; + + QFuture future = QtConcurrent::run([framesToEncode, savePath]() -> bool { + GifEncoder encoder; + bool ok = encoder.encode(framesToEncode, savePath, GifRecordViewModel::kFps, true); + if (!ok) { + qDebug() << "[GifRecord] 编码错误:" << encoder.errorString(); + } + return ok; + }); + + m_encodeWatcher->setFuture(future); +} + +QString GifRecordViewModel::generateSavePath() const +{ + QString dirPath = m_settings.savePath(); + if (dirPath.isEmpty()) { + dirPath = QDir::homePath(); + } + + QString timestamp = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss"); + return QDir(dirPath).filePath(QString("gif_%1.gif").arg(timestamp)); +} + +void GifRecordViewModel::resetState() +{ + m_recording = false; + m_encoding = false; + m_elapsedSecs = 0; + m_frames.clear(); + m_captureRect = QRect(); + + emit isRecordingChanged(); + emit isEncodingChanged(); + emit frameCountChanged(); + emit elapsedSecsChanged(); +} diff --git a/src/viewmodel/gif_record_view_model.h b/src/viewmodel/gif_record_view_model.h new file mode 100644 index 0000000..08c0855 --- /dev/null +++ b/src/viewmodel/gif_record_view_model.h @@ -0,0 +1,100 @@ +#ifndef GIF_RECORD_VIEW_MODEL_H +#define GIF_RECORD_VIEW_MODEL_H + +#include +#include +#include +#include +#include + +#include "gif_frame_grabber.h" +#include "model/app_settings.h" + +// GifRecordViewModel +// ------------------ +// GIF 录制的核心状态机,暴露给 QML。 +// +// 状态: +// Idle → (startRecording) → Recording → (stopRecording) → Encoding → Idle +// ↘ (cancelRecording) → Idle +// +// QML 属性: +// isRecording - 是否正在录制 +// isEncoding - 是否正在编码(子线程) +// frameCount - 已录帧数 +// elapsedSecs - 已录时长(秒) +// +// QML 信号: +// encodingFinished(savedPath) - 编码完成,返回保存路径 +// encodingFailed(error) - 编码失败,返回错误信息 + +class GifRecordViewModel : public QObject +{ + Q_OBJECT + + Q_PROPERTY(bool isRecording READ isRecording NOTIFY isRecordingChanged) + Q_PROPERTY(bool isEncoding READ isEncoding NOTIFY isEncodingChanged) + Q_PROPERTY(int frameCount READ frameCount NOTIFY frameCountChanged) + Q_PROPERTY(int elapsedSecs READ elapsedSecs NOTIFY elapsedSecsChanged) + +public: + explicit GifRecordViewModel(AppSettings& settings, QObject* parent = nullptr); + ~GifRecordViewModel() override; + + bool isRecording() const { return m_recording; } + bool isEncoding() const { return m_encoding; } + int frameCount() const { return m_frames.size(); } + int elapsedSecs() const { return m_elapsedSecs; } + + // QML 调用:开始录制,captureRect 为屏幕逻辑坐标选区 + Q_INVOKABLE void startRecording(int x, int y, int w, int h); + + // QML 调用:录制中实时更新抓取区域(拖动选区时调用) + Q_INVOKABLE void updateCaptureRect(int x, int y, int w, int h); + + // QML 调用:停止录制并触发编码 + Q_INVOKABLE void stopRecording(); + + // QML 调用:取消录制,丢弃所有帧 + Q_INVOKABLE void cancelRecording(); + + // 最大录制帧数(防止内存过大),默认 15fps × 30s = 450 帧 + static constexpr int kMaxFrames = 450; + +signals: + void isRecordingChanged(); + void isEncodingChanged(); + void frameCountChanged(); + void elapsedSecsChanged(); + + void encodingFinished(const QString& savedPath); + void encodingFailed(const QString& error); + +private slots: + void onFrameCaptured(const QImage& frame); + void onElapsedTimer(); + void onEncodingFinished(); + +private: + QString generateSavePath() const; + void startEncoding(); + void resetState(); + + AppSettings& m_settings; + GifFrameGrabber m_grabber; + QVector m_frames; + QRect m_captureRect; + + bool m_recording = false; + bool m_encoding = false; + int m_elapsedSecs = 0; + QTimer* m_elapsedTimer = nullptr; + + // 异步编码 + QFutureWatcher* m_encodeWatcher = nullptr; + QString m_pendingSavePath; + + static constexpr int kFps = 15; +}; + +#endif // GIF_RECORD_VIEW_MODEL_H diff --git a/src/viewmodel/gif_recorder_view_model.cpp b/src/viewmodel/gif_recorder_view_model.cpp new file mode 100644 index 0000000..9377657 --- /dev/null +++ b/src/viewmodel/gif_recorder_view_model.cpp @@ -0,0 +1,189 @@ +// 关键:仅在这一个文件中定义 STB_IMAGE_WRITE_IMPLEMENTATION,展开实现 +#define STB_IMAGE_WRITE_IMPLEMENTATION // 关键:先定义宏,再包含头文件 +#include "stb_image_write.h" + +#include "gif_recorder_view_model.h" +#include +#include +#include +#include +#include + + +GifRecorder::GifRecorder(QObject *parent) : QObject(parent) +{ + m_captureTimer = new QTimer(this); + m_captureTimer->setTimerType(Qt::PreciseTimer); + connect(m_captureTimer, &QTimer::timeout, this, &GifRecorder::captureFrame); + + // 默认录制全屏 + QScreen* screen = QApplication::primaryScreen(); + if (screen) { + m_recordRect = screen->geometry(); + } else { + m_recordRect = QRect(0, 0, 1920, 1080); // 兜底分辨率 + } + + // 确保输出目录存在 + QString outputDir = QFileInfo(m_outputPath).dir().absolutePath(); + if (!QDir(outputDir).exists()) { + QDir().mkpath(outputDir); + } +} + +GifRecorder::~GifRecorder() +{ + if (m_isRecording) { + stopRecord(); + } + // 释放帧缓存 + for (uint8_t* buf : m_frameBuffers) { + delete[] buf; + } + m_frameBuffers.clear(); + m_frameDelays.clear(); +} + +void GifRecorder::startRecord() +{ + if (m_isRecording) return; + + // 清空历史帧数据 + for (uint8_t* buf : m_frameBuffers) { + delete[] buf; + } + m_frameBuffers.clear(); + m_frameDelays.clear(); + + // 启动定时器(按帧率捕获帧) + m_isRecording = true; + emit isRecordingChanged(); + m_captureTimer->start(1000 / m_fps); + + qDebug() << "GIF 录制开始:" << m_outputPath; +} + +void GifRecorder::stopRecord() +{ + if (!m_isRecording) return; + + // 停止定时器 + m_captureTimer->stop(); + m_isRecording = false; + emit isRecordingChanged(); + + // 生成 GIF 文件 + if (!m_frameBuffers.empty()) { + // 转换帧缓存为 stbi_write_gif 所需的 const void** 格式 + const void** frames = new const void*[m_frameBuffers.size()]; + for (size_t i = 0; i < m_frameBuffers.size(); i++) { + frames[i] = m_frameBuffers[i]; + } + + // 调用 stb 生成 GIF(0=无限循环) + int result = stbi_write_gif( + m_outputPath.toUtf8().data(), + m_recordRect.width(), + m_recordRect.height(), + 3, // 通道数:RGB(3通道) + frames, + m_frameDelays.data(), + 0 // 循环次数:0=无限循环 + ); + + delete[] frames; + + if (result != 0) { + qDebug() << "GIF 生成成功:" << m_outputPath; + emit recordFinished(m_outputPath); + } else { + qCritical() << "GIF 生成失败:" << m_outputPath; + } + } else { + qWarning() << "无录制帧数据,跳过 GIF 生成"; + } +} + +void GifRecorder::captureFrame() +{ + if (!m_isRecording) return; + + // 捕获屏幕帧并转换为 RGB 格式 + QImage frame = captureScreen(m_recordRect); + if (frame.isNull()) return; + + // 转换 QImage 为 RGB 数组(stb 要求 RGB 顺序,无 Alpha) + QImage rgbFrame = frame.convertToFormat(QImage::Format_RGB888); + int frameSize = rgbFrame.width() * rgbFrame.height() * 3; + uint8_t* frameBuf = new uint8_t[frameSize]; + memcpy(frameBuf, rgbFrame.bits(), frameSize); + + // 缓存帧数据和延迟(延迟 = 1000/帧率 毫秒) + m_frameBuffers.push_back(frameBuf); + m_frameDelays.push_back(1000 / m_fps); +} + +QImage GifRecorder::captureScreen(const QRect& rect) +{ + // Windows 原生高效截图(跨平台可替换为 Qt 接口) + HDC hScreenDC = GetDC(NULL); + HDC hMemoryDC = CreateCompatibleDC(hScreenDC); + HBITMAP hBitmap = CreateCompatibleBitmap(hScreenDC, rect.width(), rect.height()); + SelectObject(hMemoryDC, hBitmap); + + BitBlt(hMemoryDC, 0, 0, rect.width(), rect.height(), + hScreenDC, rect.x(), rect.y(), SRCCOPY); + + // 转换为 QImage + BITMAPINFO bmi{}; + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = rect.width(); + bmi.bmiHeader.biHeight = -rect.height(); // 负高度:从上到下 + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 24; + bmi.bmiHeader.biCompression = BI_RGB; + + QImage image(rect.width(), rect.height(), QImage::Format_RGB888); + GetDIBits(hMemoryDC, hBitmap, 0, rect.height(), + image.bits(), &bmi, DIB_RGB_COLORS); + + // 释放资源 + DeleteObject(hBitmap); + DeleteDC(hMemoryDC); + ReleaseDC(NULL, hScreenDC); + + return image; +} + +void GifRecorder::setRecordRect(const QRect& rect) +{ + if (m_recordRect != rect) { + m_recordRect = rect; + emit recordRectChanged(); + } +} + +void GifRecorder::setFps(int fps) +{ + if (fps < 1 || fps > 30) return; + if (m_fps != fps) { + m_fps = fps; + emit fpsChanged(); + if (m_isRecording) { + m_captureTimer->setInterval(1000 / m_fps); + } + } +} + +void GifRecorder::setOutputPath(const QString& path) +{ + if (m_outputPath != path) { + m_outputPath = path; + emit outputPathChanged(); + // 确保新路径目录存在 + QString outputDir = QFileInfo(m_outputPath).dir().absolutePath(); + if (!QDir(outputDir).exists()) { + QDir().mkpath(outputDir); + } + } +} diff --git a/src/viewmodel/gif_recorder_view_model.h b/src/viewmodel/gif_recorder_view_model.h new file mode 100644 index 0000000..2da8195 --- /dev/null +++ b/src/viewmodel/gif_recorder_view_model.h @@ -0,0 +1,66 @@ +#ifndef GIFRECORDER_H +#define GIFRECORDER_H + +#include +#include +#include +#include +#include +#include +#include +#include + +// 正确声明 stbi_write_gif(extern "C" 避免 C++ 名称修饰) +extern "C" { +int stbi_write_gif(const char *filename, int w, int h, int comp, + const void **frames, const int *delays, int loop); +} +class GifRecorder : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool isRecording READ isRecording NOTIFY isRecordingChanged) + Q_PROPERTY(QRect recordRect READ recordRect WRITE setRecordRect NOTIFY recordRectChanged) + Q_PROPERTY(int fps READ fps WRITE setFps NOTIFY fpsChanged) + Q_PROPERTY(QString outputPath READ outputPath WRITE setOutputPath NOTIFY outputPathChanged) + +public: + explicit GifRecorder(QObject *parent = nullptr); + ~GifRecorder(); + + Q_INVOKABLE void startRecord(); + Q_INVOKABLE void stopRecord(); + + bool isRecording() const { return m_isRecording; } + QRect recordRect() const { return m_recordRect; } + void setRecordRect(const QRect& rect); + int fps() const { return m_fps; } + void setFps(int fps); + QString outputPath() const { return m_outputPath; } + void setOutputPath(const QString& path); + +signals: + void isRecordingChanged(); + void recordRectChanged(); + void fpsChanged(); + void outputPathChanged(); + void recordFinished(const QString& filePath); + +private slots: + void captureFrame(); + +private: + QImage captureScreen(const QRect& rect); + + // 录制状态 + bool m_isRecording = false; + QRect m_recordRect; + int m_fps = 10; + QString m_outputPath = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation) + "/record.gif"; + QTimer* m_captureTimer = nullptr; + + // stb_image_write 所需的帧数据缓存 + std::vector m_frameBuffers; // 存储每帧的 RGB 数据 + std::vector m_frameDelays; // 存储每帧的延迟(毫秒) +}; + +#endif // GIFRECORDER_H diff --git a/src/viewmodel/ocr_view_model.cpp b/src/viewmodel/ocr_view_model.cpp new file mode 100644 index 0000000..3d6df33 --- /dev/null +++ b/src/viewmodel/ocr_view_model.cpp @@ -0,0 +1,78 @@ +#include "ocr_view_model.h" +#include "ocr/ocr_engine.h" + +#include +#include +#include +#include + +// ── 构造 ───────────────────────────────────────────────── +OcrViewModel::OcrViewModel(QObject *parent) + : QObject(parent) +{ + connect(&m_watcher, &QFutureWatcher::finished, + this, &OcrViewModel::onRecognitionFinished); +} + +// ── recognize ──────────────────────────────────────────── +void OcrViewModel::recognize(const QImage &image) +{ + if (m_isRecognizing) { + qWarning() << "[OcrViewModel] 正在识别中,忽略重复请求"; + return; + } + if (image.isNull()) { + emit recognizeFailed("输入图像为空"); + return; + } + + m_isRecognizing = true; + emit isRecognizingChanged(); + + // 在子线程中执行同步识别;OcrEngine 在 lambda 内部构造/销毁(线程安全) + QImage imgCopy = image; // 拷贝,避免主线程释放 + QFuture future = QtConcurrent::run([imgCopy]() -> QString { + OcrEngine engine; + if (!engine.isReady()) + return QString("__ERROR__:") + engine.lastError(); + QString result = engine.recognize(imgCopy); + if (result.isEmpty() && !engine.lastError().isEmpty()) + return QString("__ERROR__:") + engine.lastError(); + return result; + }); + + m_watcher.setFuture(future); +} + +// ── onRecognitionFinished ───────────────────────────────── +void OcrViewModel::onRecognitionFinished() +{ + m_isRecognizing = false; + emit isRecognizingChanged(); + + QString result = m_watcher.result(); + + if (result.startsWith("__ERROR__:")) { + QString err = result.mid(10); + qWarning() << "[OcrViewModel] 识别失败:" << err; + emit recognizeFailed(err); + return; + } + + m_resultText = result; + emit resultTextChanged(); + emit resultReady(result); +} + +// ── copyResultToClipboard ───────────────────────────────── +void OcrViewModel::copyResultToClipboard() +{ + if (m_resultText.isEmpty()) return; + QClipboard *cb = QGuiApplication::clipboard(); + if (cb) { + cb->setText(m_resultText, QClipboard::Clipboard); +#ifdef Q_OS_LINUX + cb->setText(m_resultText, QClipboard::Selection); +#endif + } +} diff --git a/src/viewmodel/ocr_view_model.h b/src/viewmodel/ocr_view_model.h new file mode 100644 index 0000000..3330e44 --- /dev/null +++ b/src/viewmodel/ocr_view_model.h @@ -0,0 +1,50 @@ +#ifndef OCR_VIEW_MODEL_H +#define OCR_VIEW_MODEL_H + +#include +#include +#include +#include + +// OcrViewModel +// ------------ +// 暴露给 QML 的 OCR 状态机 ViewModel(QML 上下文名:O_OcrVM)。 +// recognize() 在 QtConcurrent 子线程中调用 OcrEngine,不阻塞 UI 线程。 + +class OcrViewModel : public QObject +{ + Q_OBJECT + + Q_PROPERTY(bool isRecognizing READ isRecognizing NOTIFY isRecognizingChanged) + Q_PROPERTY(QString resultText READ resultText NOTIFY resultTextChanged) + +public: + explicit OcrViewModel(QObject *parent = nullptr); + ~OcrViewModel() override = default; + + bool isRecognizing() const { return m_isRecognizing; } + QString resultText() const { return m_resultText; } + + // 提交识别任务(供 QML 调用) + Q_INVOKABLE void recognize(const QImage &image); + + // 将结果文本复制到系统剪贴板 + Q_INVOKABLE void copyResultToClipboard(); + +signals: + void isRecognizingChanged(); + void resultTextChanged(); + void resultReady(const QString &text); + void recognizeFailed(const QString &errorMessage); + +private slots: + void onRecognitionFinished(); + +private: + bool m_isRecognizing = false; + QString m_resultText; + + QFutureWatcher m_watcher; +}; + +#endif // OCR_VIEW_MODEL_H diff --git a/src/viewmodel/screenshot_view_model.cpp b/src/viewmodel/screenshot_view_model.cpp new file mode 100644 index 0000000..af401ab --- /dev/null +++ b/src/viewmodel/screenshot_view_model.cpp @@ -0,0 +1,214 @@ +#include "screenshot_view_model.h" +#include "paint_board/paint_board.h" + +#include +#include +#include +#include +#include +#include +#include + +ScreenshotViewModel::ScreenshotViewModel(DesktopSnapshot &snapshot, + StickyImageStore &stickyStore, + QObject *parent) + : QObject(parent) + , m_snapshot(snapshot) + , m_stickyStore(stickyStore) +{ +} + +// ── captureDesktop ──────────────────────────────────────── +void ScreenshotViewModel::captureDesktop() +{ + m_snapshot.grab(); +} + +// ── releaseDesktopSnapshot ──────────────────────────────── +void ScreenshotViewModel::releaseDesktopSnapshot() +{ + m_snapshot.release(); +} + +// ── getPixelColor ───────────────────────────────────────── +QColor ScreenshotViewModel::getPixelColor(int x, int y) const +{ + return m_snapshot.pixelColor(x, y); +} + +// ── windowAtPoint ───────────────────────────────────────── +QRect ScreenshotViewModel::windowAtPoint(int x, int y) const +{ + return m_snapshot.windowAtPoint(x, y); +} + +// ── captureRectToClipboard ──────────────────────────────── +bool ScreenshotViewModel::captureRectToClipboard(QQuickItem *paintBoard, const QRect &rect) +{ + const QRect r = rect.normalized(); + if (r.isEmpty()) { + qWarning() << "[ScreenshotViewModel] 截图区域为空"; + return false; + } + QImage img = captureScreen(paintBoard, r); + if (img.isNull()) { + qWarning() << "[ScreenshotViewModel] 截图失败,捕获到空图片"; + return false; + } + return writeImageToClipboard(img); +} + +// ── captureRectToBase64 ─────────────────────────────────── +QString ScreenshotViewModel::captureRectToBase64(QQuickItem *paintBoard, const QRect &rect) +{ + const QRect r = rect.normalized(); + if (r.isEmpty()) { + qWarning() << "[ScreenshotViewModel] 截图区域为空"; + return {}; + } + QImage img = captureScreen(paintBoard, r); + if (img.isNull()) { + qWarning() << "[ScreenshotViewModel] 截图失败,捕获到空图片"; + return {}; + } + QByteArray data; + QBuffer buf(&data); + buf.open(QIODevice::WriteOnly); + if (!img.save(&buf, "PNG")) { + qWarning() << "[ScreenshotViewModel] PNG 编码失败"; + return {}; + } + return data.toBase64(); +} + +// ── captureRectToStickyUrl ──────────────────────────────── +QString ScreenshotViewModel::captureRectToStickyUrl(QQuickItem *paintBoard, const QRect &rect) +{ + const QRect r = rect.normalized(); + if (r.isEmpty()) { + qWarning() << "[ScreenshotViewModel] 贴图区域为空"; + return {}; + } + QImage img = captureScreen(paintBoard, r); + if (img.isNull()) { + qWarning() << "[ScreenshotViewModel] 贴图截图失败"; + return {}; + } + return m_stickyStore.storeImage(img); +} + +// ── grabToPaintBoard ────────────────────────────────────── +void ScreenshotViewModel::grabToPaintBoard(QQuickItem *paintBoard, const QRect &rect) +{ + const QRect r = rect.normalized(); + if (r.isEmpty()) return; + captureScreen(paintBoard, r, /*setBackground=*/true); +} + +// ── captureRectToImage ──────────────────────────────────── +QImage ScreenshotViewModel::captureRectToImage(const QRect &rect) +{ + const QRect r = rect.normalized(); + if (r.isEmpty()) { + qWarning() << "[ScreenshotViewModel] OCR 区域为空"; + return {}; + } + + if (!m_snapshot.isNull()) { + const QRect vg = m_snapshot.virtualGeometry(); + const qreal scaleX = vg.width() > 0 ? qreal(m_snapshot.image().width()) / qreal(vg.width()) : 1.0; + const qreal scaleY = vg.height() > 0 ? qreal(m_snapshot.image().height()) / qreal(vg.height()) : 1.0; + + const QRect physRect = QRect(qRound((r.x() - vg.x()) * scaleX), + qRound((r.y() - vg.y()) * scaleY), + qRound(r.width() * scaleX), + qRound(r.height() * scaleY)) + .intersected(m_snapshot.image().rect()); + return m_snapshot.image().copy(physRect); + } + + qWarning() << "[ScreenshotViewModel] 快照未缓存,OCR 回退到实时抓屏(仅主屏)"; + QScreen *screen = QGuiApplication::primaryScreen(); + if (!screen) return {}; + return screen->grabWindow(0, r.x(), r.y(), r.width(), r.height()).toImage(); +} + +// ── captureScreen (private) ─────────────────────────────── +QImage ScreenshotViewModel::captureScreen(QQuickItem *paintBoard, + const QRect &rect, + bool setBackground) +{ + PaintBoard *board = qobject_cast(paintBoard); + + QImage screenshot; + + if (!m_snapshot.isNull()) { + // 快照坐标系:逻辑坐标相对于虚拟桌面原点;物理像素按统一 DPR 缩放。 + // 将逻辑选区映射到快照物理像素矩形后裁剪。 + const QRect vg = m_snapshot.virtualGeometry(); + const qreal scaleX = vg.width() > 0 ? qreal(m_snapshot.image().width()) / qreal(vg.width()) : 1.0; + const qreal scaleY = vg.height() > 0 ? qreal(m_snapshot.image().height()) / qreal(vg.height()) : 1.0; + + const QRect physRect = rect.isEmpty() + ? m_snapshot.image().rect() + : QRect(qRound((rect.x() - vg.x()) * scaleX), + qRound((rect.y() - vg.y()) * scaleY), + qRound(rect.width() * scaleX), + qRound(rect.height() * scaleY)) + .intersected(m_snapshot.image().rect()); + + screenshot = m_snapshot.image().copy(physRect); + } else { + qWarning() << "[ScreenshotViewModel] 快照未缓存,回退到实时抓屏(仅主屏)"; + QScreen *screen = QGuiApplication::primaryScreen(); + if (!screen) return {}; + screenshot = screen->grabWindow(0).toImage(); + } + + if (board) { + const QImage annotations = board->renderToImage(); + if (!annotations.isNull()) { + QPainter p(&screenshot); + + // PaintBoard 现在是全屏画布:导出选区时,需要从全屏标注层裁剪对应区域再叠加 + if (!rect.isEmpty()) { + QQuickWindow *win = board->window(); + const int windowAbsX = win ? win->x() : 0; + const int windowAbsY = win ? win->y() : 0; + const int boardAbsX = windowAbsX + qRound(board->x()); + const int boardAbsY = windowAbsY + qRound(board->y()); + + const int wantX = rect.x() - boardAbsX; + const int wantY = rect.y() - boardAbsY; + QRect srcRect(wantX, wantY, screenshot.width(), screenshot.height()); + srcRect = srcRect.intersected(annotations.rect()); + + if (!srcRect.isEmpty()) { + const int dstX = srcRect.x() - wantX; + const int dstY = srcRect.y() - wantY; + p.drawImage(QPoint(dstX, dstY), annotations, srcRect); + } + } else { + p.drawImage(0, 0, annotations); + } + } + + if (setBackground) + board->setBackgroundImg(screenshot); + } + + return screenshot; +} + +// ── writeImageToClipboard (private) ────────────────────── +bool ScreenshotViewModel::writeImageToClipboard(const QImage &image) +{ + if (image.isNull()) return false; + QClipboard *cb = QGuiApplication::clipboard(); + if (!cb) return false; + cb->setImage(image, QClipboard::Clipboard); +#ifdef Q_OS_LINUX + cb->setImage(image, QClipboard::Selection); +#endif + return true; +} diff --git a/src/viewmodel/screenshot_view_model.h b/src/viewmodel/screenshot_view_model.h new file mode 100644 index 0000000..3cd3453 --- /dev/null +++ b/src/viewmodel/screenshot_view_model.h @@ -0,0 +1,74 @@ +#ifndef SCREENSHOT_VIEW_MODEL_H +#define SCREENSHOT_VIEW_MODEL_H + +#include +#include +#include +#include +#include "model/desktop_snapshot.h" +#include "model/app_settings.h" +#include "sticky_image_store.h" + +// ScreenshotViewModel +// ------------------- +// 编排截图完整流程:抓快照 → 裁剪 → 合并标注 → 输出(剪贴板/Base64/贴图)。 +// 暴露 Q_INVOKABLE 方法和信号供 QML 调用,自身不感知任何 QML 组件 id。 +// 依赖的 Model:DesktopSnapshot(数据)、StickyImageStore(贴图存储)。 + +class ScreenshotViewModel : public QObject +{ + Q_OBJECT + +public: + explicit ScreenshotViewModel(DesktopSnapshot &snapshot, + StickyImageStore &stickyStore, + QObject *parent = nullptr); + + // ── 供 MagnifierProvider 只读访问快照 ──────────────── + const QImage& desktopSnapshot() const { return m_snapshot.image(); } + + // 虚拟桌面的逻辑矩形(所有屏幕 geometry 的并集),供 QML 定位截图窗口 + Q_INVOKABLE QRect virtualGeometry() const { return m_snapshot.virtualGeometry(); } + + // ── Q_INVOKABLE:供 QML 调用 ───────────────────────── + + // 抓取全屏快照(在截图窗口显示之前调用) + Q_INVOKABLE void captureDesktop(); + + // 释放快照(截图会话结束后调用) + Q_INVOKABLE void releaseDesktopSnapshot(); + + // 取指定逻辑坐标的像素颜色(放大镜取色) + Q_INVOKABLE QColor getPixelColor(int x, int y) const; + + // 返回 (x,y) 下最上层窗口矩形(窗口识别) + Q_INVOKABLE QRect windowAtPoint(int x, int y) const; + + // 截图选区并写入剪贴板,返回是否成功 + Q_INVOKABLE bool captureRectToClipboard(QQuickItem *paintBoard, const QRect &rect); + + // 截图选区并返回 Base64 字符串(供保存文件使用,不写剪贴板) + Q_INVOKABLE QString captureRectToBase64(QQuickItem *paintBoard, const QRect &rect); + + // 截图选区并存入 StickyImageStore,返回 image://sticky/ URL + Q_INVOKABLE QString captureRectToStickyUrl(QQuickItem *paintBoard, const QRect &rect); + + // 仅将选区写入 PaintBoard 背景(马赛克工具使用) + Q_INVOKABLE void grabToPaintBoard(QQuickItem *paintBoard, const QRect &rect); + + // 截图选区并直接返回 QImage(OCR 使用) + Q_INVOKABLE QImage captureRectToImage(const QRect &rect); + +private: + // 内部:从快照裁剪选区并叠加 PaintBoard 标注层 + // setBackground=true 时将结果写回 PaintBoard 背景(仅马赛克路径) + QImage captureScreen(QQuickItem *paintBoard, const QRect &rect = QRect(), + bool setBackground = false); + + bool writeImageToClipboard(const QImage &image); + + DesktopSnapshot &m_snapshot; + StickyImageStore &m_stickyStore; +}; + +#endif // SCREENSHOT_VIEW_MODEL_H diff --git a/src/viewmodel/stb_image_write.h b/src/viewmodel/stb_image_write.h new file mode 100644 index 0000000..e4b32ed --- /dev/null +++ b/src/viewmodel/stb_image_write.h @@ -0,0 +1,1724 @@ +/* stb_image_write - v1.16 - public domain - http://nothings.org/stb + writes out PNG/BMP/TGA/JPEG/HDR images to C stdio - Sean Barrett 2010-2015 + no warranty implied; use at your own risk + + Before #including, + + #define STB_IMAGE_WRITE_IMPLEMENTATION + + in the file that you want to have the implementation. + + Will probably not work correctly with strict-aliasing optimizations. + +ABOUT: + + This header file is a library for writing images to C stdio or a callback. + + The PNG output is not optimal; it is 20-50% larger than the file + written by a decent optimizing implementation; though providing a custom + zlib compress function (see STBIW_ZLIB_COMPRESS) can mitigate that. + This library is designed for source code compactness and simplicity, + not optimal image file size or run-time performance. + +BUILDING: + + You can #define STBIW_ASSERT(x) before the #include to avoid using assert.h. + You can #define STBIW_MALLOC(), STBIW_REALLOC(), and STBIW_FREE() to replace + malloc,realloc,free. + You can #define STBIW_MEMMOVE() to replace memmove() + You can #define STBIW_ZLIB_COMPRESS to use a custom zlib-style compress function + for PNG compression (instead of the builtin one), it must have the following signature: + unsigned char * my_compress(unsigned char *data, int data_len, int *out_len, int quality); + The returned data will be freed with STBIW_FREE() (free() by default), + so it must be heap allocated with STBIW_MALLOC() (malloc() by default), + +UNICODE: + + If compiling for Windows and you wish to use Unicode filenames, compile + with + #define STBIW_WINDOWS_UTF8 + and pass utf8-encoded filenames. Call stbiw_convert_wchar_to_utf8 to convert + Windows wchar_t filenames to utf8. + +USAGE: + + There are five functions, one for each image file format: + + int stbi_write_png(char const *filename, int w, int h, int comp, const void *data, int stride_in_bytes); + int stbi_write_bmp(char const *filename, int w, int h, int comp, const void *data); + int stbi_write_tga(char const *filename, int w, int h, int comp, const void *data); + int stbi_write_jpg(char const *filename, int w, int h, int comp, const void *data, int quality); + int stbi_write_hdr(char const *filename, int w, int h, int comp, const float *data); + + void stbi_flip_vertically_on_write(int flag); // flag is non-zero to flip data vertically + + There are also five equivalent functions that use an arbitrary write function. You are + expected to open/close your file-equivalent before and after calling these: + + int stbi_write_png_to_func(stbi_write_func *func, void *context, int w, int h, int comp, const void *data, int stride_in_bytes); + int stbi_write_bmp_to_func(stbi_write_func *func, void *context, int w, int h, int comp, const void *data); + int stbi_write_tga_to_func(stbi_write_func *func, void *context, int w, int h, int comp, const void *data); + int stbi_write_hdr_to_func(stbi_write_func *func, void *context, int w, int h, int comp, const float *data); + int stbi_write_jpg_to_func(stbi_write_func *func, void *context, int x, int y, int comp, const void *data, int quality); + + where the callback is: + void stbi_write_func(void *context, void *data, int size); + + You can configure it with these global variables: + int stbi_write_tga_with_rle; // defaults to true; set to 0 to disable RLE + int stbi_write_png_compression_level; // defaults to 8; set to higher for more compression + int stbi_write_force_png_filter; // defaults to -1; set to 0..5 to force a filter mode + + + You can define STBI_WRITE_NO_STDIO to disable the file variant of these + functions, so the library will not use stdio.h at all. However, this will + also disable HDR writing, because it requires stdio for formatted output. + + Each function returns 0 on failure and non-0 on success. + + The functions create an image file defined by the parameters. The image + is a rectangle of pixels stored from left-to-right, top-to-bottom. + Each pixel contains 'comp' channels of data stored interleaved with 8-bits + per channel, in the following order: 1=Y, 2=YA, 3=RGB, 4=RGBA. (Y is + monochrome color.) The rectangle is 'w' pixels wide and 'h' pixels tall. + The *data pointer points to the first byte of the top-left-most pixel. + For PNG, "stride_in_bytes" is the distance in bytes from the first byte of + a row of pixels to the first byte of the next row of pixels. + + PNG creates output files with the same number of components as the input. + The BMP format expands Y to RGB in the file format and does not + output alpha. + + PNG supports writing rectangles of data even when the bytes storing rows of + data are not consecutive in memory (e.g. sub-rectangles of a larger image), + by supplying the stride between the beginning of adjacent rows. The other + formats do not. (Thus you cannot write a native-format BMP through the BMP + writer, both because it is in BGR order and because it may have padding + at the end of the line.) + + PNG allows you to set the deflate compression level by setting the global + variable 'stbi_write_png_compression_level' (it defaults to 8). + + HDR expects linear float data. Since the format is always 32-bit rgb(e) + data, alpha (if provided) is discarded, and for monochrome data it is + replicated across all three channels. + + TGA supports RLE or non-RLE compressed data. To use non-RLE-compressed + data, set the global variable 'stbi_write_tga_with_rle' to 0. + + JPEG does ignore alpha channels in input data; quality is between 1 and 100. + Higher quality looks better but results in a bigger image. + JPEG baseline (no JPEG progressive). + +CREDITS: + + + Sean Barrett - PNG/BMP/TGA + Baldur Karlsson - HDR + Jean-Sebastien Guay - TGA monochrome + Tim Kelsey - misc enhancements + Alan Hickman - TGA RLE + Emmanuel Julien - initial file IO callback implementation + Jon Olick - original jo_jpeg.cpp code + Daniel Gibson - integrate JPEG, allow external zlib + Aarni Koskela - allow choosing PNG filter + + bugfixes: + github:Chribba + Guillaume Chereau + github:jry2 + github:romigrou + Sergio Gonzalez + Jonas Karlsson + Filip Wasil + Thatcher Ulrich + github:poppolopoppo + Patrick Boettcher + github:xeekworx + Cap Petschulat + Simon Rodriguez + Ivan Tikhonov + github:ignotion + Adam Schackart + Andrew Kensler + +LICENSE + + See end of file for license information. + +*/ + +#ifndef INCLUDE_STB_IMAGE_WRITE_H +#define INCLUDE_STB_IMAGE_WRITE_H + +#include + +// if STB_IMAGE_WRITE_STATIC causes problems, try defining STBIWDEF to 'inline' or 'static inline' +#ifndef STBIWDEF +#ifdef STB_IMAGE_WRITE_STATIC +#define STBIWDEF static +#else +#ifdef __cplusplus +#define STBIWDEF extern "C" +#else +#define STBIWDEF extern +#endif +#endif +#endif + +#ifndef STB_IMAGE_WRITE_STATIC // C++ forbids static forward declarations +STBIWDEF int stbi_write_tga_with_rle; +STBIWDEF int stbi_write_png_compression_level; +STBIWDEF int stbi_write_force_png_filter; +#endif + +#ifndef STBI_WRITE_NO_STDIO +STBIWDEF int stbi_write_png(char const *filename, int w, int h, int comp, const void *data, int stride_in_bytes); +STBIWDEF int stbi_write_bmp(char const *filename, int w, int h, int comp, const void *data); +STBIWDEF int stbi_write_tga(char const *filename, int w, int h, int comp, const void *data); +STBIWDEF int stbi_write_hdr(char const *filename, int w, int h, int comp, const float *data); +STBIWDEF int stbi_write_jpg(char const *filename, int x, int y, int comp, const void *data, int quality); + +#ifdef STBIW_WINDOWS_UTF8 +STBIWDEF int stbiw_convert_wchar_to_utf8(char *buffer, size_t bufferlen, const wchar_t* input); +#endif +#endif + +typedef void stbi_write_func(void *context, void *data, int size); + +STBIWDEF int stbi_write_png_to_func(stbi_write_func *func, void *context, int w, int h, int comp, const void *data, int stride_in_bytes); +STBIWDEF int stbi_write_bmp_to_func(stbi_write_func *func, void *context, int w, int h, int comp, const void *data); +STBIWDEF int stbi_write_tga_to_func(stbi_write_func *func, void *context, int w, int h, int comp, const void *data); +STBIWDEF int stbi_write_hdr_to_func(stbi_write_func *func, void *context, int w, int h, int comp, const float *data); +STBIWDEF int stbi_write_jpg_to_func(stbi_write_func *func, void *context, int x, int y, int comp, const void *data, int quality); + +STBIWDEF void stbi_flip_vertically_on_write(int flip_boolean); + +#endif//INCLUDE_STB_IMAGE_WRITE_H + +#ifdef STB_IMAGE_WRITE_IMPLEMENTATION + +#ifdef _WIN32 + #ifndef _CRT_SECURE_NO_WARNINGS + #define _CRT_SECURE_NO_WARNINGS + #endif + #ifndef _CRT_NONSTDC_NO_DEPRECATE + #define _CRT_NONSTDC_NO_DEPRECATE + #endif +#endif + +#ifndef STBI_WRITE_NO_STDIO +#include +#endif // STBI_WRITE_NO_STDIO + +#include +#include +#include +#include + +#if defined(STBIW_MALLOC) && defined(STBIW_FREE) && (defined(STBIW_REALLOC) || defined(STBIW_REALLOC_SIZED)) +// ok +#elif !defined(STBIW_MALLOC) && !defined(STBIW_FREE) && !defined(STBIW_REALLOC) && !defined(STBIW_REALLOC_SIZED) +// ok +#else +#error "Must define all or none of STBIW_MALLOC, STBIW_FREE, and STBIW_REALLOC (or STBIW_REALLOC_SIZED)." +#endif + +#ifndef STBIW_MALLOC +#define STBIW_MALLOC(sz) malloc(sz) +#define STBIW_REALLOC(p,newsz) realloc(p,newsz) +#define STBIW_FREE(p) free(p) +#endif + +#ifndef STBIW_REALLOC_SIZED +#define STBIW_REALLOC_SIZED(p,oldsz,newsz) STBIW_REALLOC(p,newsz) +#endif + + +#ifndef STBIW_MEMMOVE +#define STBIW_MEMMOVE(a,b,sz) memmove(a,b,sz) +#endif + + +#ifndef STBIW_ASSERT +#include +#define STBIW_ASSERT(x) assert(x) +#endif + +#define STBIW_UCHAR(x) (unsigned char) ((x) & 0xff) + +#ifdef STB_IMAGE_WRITE_STATIC +static int stbi_write_png_compression_level = 8; +static int stbi_write_tga_with_rle = 1; +static int stbi_write_force_png_filter = -1; +#else +int stbi_write_png_compression_level = 8; +int stbi_write_tga_with_rle = 1; +int stbi_write_force_png_filter = -1; +#endif + +static int stbi__flip_vertically_on_write = 0; + +STBIWDEF void stbi_flip_vertically_on_write(int flag) +{ + stbi__flip_vertically_on_write = flag; +} + +typedef struct +{ + stbi_write_func *func; + void *context; + unsigned char buffer[64]; + int buf_used; +} stbi__write_context; + +// initialize a callback-based context +static void stbi__start_write_callbacks(stbi__write_context *s, stbi_write_func *c, void *context) +{ + s->func = c; + s->context = context; +} + +#ifndef STBI_WRITE_NO_STDIO + +static void stbi__stdio_write(void *context, void *data, int size) +{ + fwrite(data,1,size,(FILE*) context); +} + +#if defined(_WIN32) && defined(STBIW_WINDOWS_UTF8) +#ifdef __cplusplus +#define STBIW_EXTERN extern "C" +#else +#define STBIW_EXTERN extern +#endif +STBIW_EXTERN __declspec(dllimport) int __stdcall MultiByteToWideChar(unsigned int cp, unsigned long flags, const char *str, int cbmb, wchar_t *widestr, int cchwide); +STBIW_EXTERN __declspec(dllimport) int __stdcall WideCharToMultiByte(unsigned int cp, unsigned long flags, const wchar_t *widestr, int cchwide, char *str, int cbmb, const char *defchar, int *used_default); + +STBIWDEF int stbiw_convert_wchar_to_utf8(char *buffer, size_t bufferlen, const wchar_t* input) +{ + return WideCharToMultiByte(65001 /* UTF8 */, 0, input, -1, buffer, (int) bufferlen, NULL, NULL); +} +#endif + +static FILE *stbiw__fopen(char const *filename, char const *mode) +{ + FILE *f; +#if defined(_WIN32) && defined(STBIW_WINDOWS_UTF8) + wchar_t wMode[64]; + wchar_t wFilename[1024]; + if (0 == MultiByteToWideChar(65001 /* UTF8 */, 0, filename, -1, wFilename, sizeof(wFilename)/sizeof(*wFilename))) + return 0; + + if (0 == MultiByteToWideChar(65001 /* UTF8 */, 0, mode, -1, wMode, sizeof(wMode)/sizeof(*wMode))) + return 0; + +#if defined(_MSC_VER) && _MSC_VER >= 1400 + if (0 != _wfopen_s(&f, wFilename, wMode)) + f = 0; +#else + f = _wfopen(wFilename, wMode); +#endif + +#elif defined(_MSC_VER) && _MSC_VER >= 1400 + if (0 != fopen_s(&f, filename, mode)) + f=0; +#else + f = fopen(filename, mode); +#endif + return f; +} + +static int stbi__start_write_file(stbi__write_context *s, const char *filename) +{ + FILE *f = stbiw__fopen(filename, "wb"); + stbi__start_write_callbacks(s, stbi__stdio_write, (void *) f); + return f != NULL; +} + +static void stbi__end_write_file(stbi__write_context *s) +{ + fclose((FILE *)s->context); +} + +#endif // !STBI_WRITE_NO_STDIO + +typedef unsigned int stbiw_uint32; +typedef int stb_image_write_test[sizeof(stbiw_uint32)==4 ? 1 : -1]; + +static void stbiw__writefv(stbi__write_context *s, const char *fmt, va_list v) +{ + while (*fmt) { + switch (*fmt++) { + case ' ': break; + case '1': { unsigned char x = STBIW_UCHAR(va_arg(v, int)); + s->func(s->context,&x,1); + break; } + case '2': { int x = va_arg(v,int); + unsigned char b[2]; + b[0] = STBIW_UCHAR(x); + b[1] = STBIW_UCHAR(x>>8); + s->func(s->context,b,2); + break; } + case '4': { stbiw_uint32 x = va_arg(v,int); + unsigned char b[4]; + b[0]=STBIW_UCHAR(x); + b[1]=STBIW_UCHAR(x>>8); + b[2]=STBIW_UCHAR(x>>16); + b[3]=STBIW_UCHAR(x>>24); + s->func(s->context,b,4); + break; } + default: + STBIW_ASSERT(0); + return; + } + } +} + +static void stbiw__writef(stbi__write_context *s, const char *fmt, ...) +{ + va_list v; + va_start(v, fmt); + stbiw__writefv(s, fmt, v); + va_end(v); +} + +static void stbiw__write_flush(stbi__write_context *s) +{ + if (s->buf_used) { + s->func(s->context, &s->buffer, s->buf_used); + s->buf_used = 0; + } +} + +static void stbiw__putc(stbi__write_context *s, unsigned char c) +{ + s->func(s->context, &c, 1); +} + +static void stbiw__write1(stbi__write_context *s, unsigned char a) +{ + if ((size_t)s->buf_used + 1 > sizeof(s->buffer)) + stbiw__write_flush(s); + s->buffer[s->buf_used++] = a; +} + +static void stbiw__write3(stbi__write_context *s, unsigned char a, unsigned char b, unsigned char c) +{ + int n; + if ((size_t)s->buf_used + 3 > sizeof(s->buffer)) + stbiw__write_flush(s); + n = s->buf_used; + s->buf_used = n+3; + s->buffer[n+0] = a; + s->buffer[n+1] = b; + s->buffer[n+2] = c; +} + +static void stbiw__write_pixel(stbi__write_context *s, int rgb_dir, int comp, int write_alpha, int expand_mono, unsigned char *d) +{ + unsigned char bg[3] = { 255, 0, 255}, px[3]; + int k; + + if (write_alpha < 0) + stbiw__write1(s, d[comp - 1]); + + switch (comp) { + case 2: // 2 pixels = mono + alpha, alpha is written separately, so same as 1-channel case + case 1: + if (expand_mono) + stbiw__write3(s, d[0], d[0], d[0]); // monochrome bmp + else + stbiw__write1(s, d[0]); // monochrome TGA + break; + case 4: + if (!write_alpha) { + // composite against pink background + for (k = 0; k < 3; ++k) + px[k] = bg[k] + ((d[k] - bg[k]) * d[3]) / 255; + stbiw__write3(s, px[1 - rgb_dir], px[1], px[1 + rgb_dir]); + break; + } + /* FALLTHROUGH */ + case 3: + stbiw__write3(s, d[1 - rgb_dir], d[1], d[1 + rgb_dir]); + break; + } + if (write_alpha > 0) + stbiw__write1(s, d[comp - 1]); +} + +static void stbiw__write_pixels(stbi__write_context *s, int rgb_dir, int vdir, int x, int y, int comp, void *data, int write_alpha, int scanline_pad, int expand_mono) +{ + stbiw_uint32 zero = 0; + int i,j, j_end; + + if (y <= 0) + return; + + if (stbi__flip_vertically_on_write) + vdir *= -1; + + if (vdir < 0) { + j_end = -1; j = y-1; + } else { + j_end = y; j = 0; + } + + for (; j != j_end; j += vdir) { + for (i=0; i < x; ++i) { + unsigned char *d = (unsigned char *) data + (j*x+i)*comp; + stbiw__write_pixel(s, rgb_dir, comp, write_alpha, expand_mono, d); + } + stbiw__write_flush(s); + s->func(s->context, &zero, scanline_pad); + } +} + +static int stbiw__outfile(stbi__write_context *s, int rgb_dir, int vdir, int x, int y, int comp, int expand_mono, void *data, int alpha, int pad, const char *fmt, ...) +{ + if (y < 0 || x < 0) { + return 0; + } else { + va_list v; + va_start(v, fmt); + stbiw__writefv(s, fmt, v); + va_end(v); + stbiw__write_pixels(s,rgb_dir,vdir,x,y,comp,data,alpha,pad, expand_mono); + return 1; + } +} + +static int stbi_write_bmp_core(stbi__write_context *s, int x, int y, int comp, const void *data) +{ + if (comp != 4) { + // write RGB bitmap + int pad = (-x*3) & 3; + return stbiw__outfile(s,-1,-1,x,y,comp,1,(void *) data,0,pad, + "11 4 22 4" "4 44 22 444444", + 'B', 'M', 14+40+(x*3+pad)*y, 0,0, 14+40, // file header + 40, x,y, 1,24, 0,0,0,0,0,0); // bitmap header + } else { + // RGBA bitmaps need a v4 header + // use BI_BITFIELDS mode with 32bpp and alpha mask + // (straight BI_RGB with alpha mask doesn't work in most readers) + return stbiw__outfile(s,-1,-1,x,y,comp,1,(void *)data,1,0, + "11 4 22 4" "4 44 22 444444 4444 4 444 444 444 444", + 'B', 'M', 14+108+x*y*4, 0, 0, 14+108, // file header + 108, x,y, 1,32, 3,0,0,0,0,0, 0xff0000,0xff00,0xff,0xff000000u, 0, 0,0,0, 0,0,0, 0,0,0, 0,0,0); // bitmap V4 header + } +} + +STBIWDEF int stbi_write_bmp_to_func(stbi_write_func *func, void *context, int x, int y, int comp, const void *data) +{ + stbi__write_context s = { 0 }; + stbi__start_write_callbacks(&s, func, context); + return stbi_write_bmp_core(&s, x, y, comp, data); +} + +#ifndef STBI_WRITE_NO_STDIO +STBIWDEF int stbi_write_bmp(char const *filename, int x, int y, int comp, const void *data) +{ + stbi__write_context s = { 0 }; + if (stbi__start_write_file(&s,filename)) { + int r = stbi_write_bmp_core(&s, x, y, comp, data); + stbi__end_write_file(&s); + return r; + } else + return 0; +} +#endif //!STBI_WRITE_NO_STDIO + +static int stbi_write_tga_core(stbi__write_context *s, int x, int y, int comp, void *data) +{ + int has_alpha = (comp == 2 || comp == 4); + int colorbytes = has_alpha ? comp-1 : comp; + int format = colorbytes < 2 ? 3 : 2; // 3 color channels (RGB/RGBA) = 2, 1 color channel (Y/YA) = 3 + + if (y < 0 || x < 0) + return 0; + + if (!stbi_write_tga_with_rle) { + return stbiw__outfile(s, -1, -1, x, y, comp, 0, (void *) data, has_alpha, 0, + "111 221 2222 11", 0, 0, format, 0, 0, 0, 0, 0, x, y, (colorbytes + has_alpha) * 8, has_alpha * 8); + } else { + int i,j,k; + int jend, jdir; + + stbiw__writef(s, "111 221 2222 11", 0,0,format+8, 0,0,0, 0,0,x,y, (colorbytes + has_alpha) * 8, has_alpha * 8); + + if (stbi__flip_vertically_on_write) { + j = 0; + jend = y; + jdir = 1; + } else { + j = y-1; + jend = -1; + jdir = -1; + } + for (; j != jend; j += jdir) { + unsigned char *row = (unsigned char *) data + j * x * comp; + int len; + + for (i = 0; i < x; i += len) { + unsigned char *begin = row + i * comp; + int diff = 1; + len = 1; + + if (i < x - 1) { + ++len; + diff = memcmp(begin, row + (i + 1) * comp, comp); + if (diff) { + const unsigned char *prev = begin; + for (k = i + 2; k < x && len < 128; ++k) { + if (memcmp(prev, row + k * comp, comp)) { + prev += comp; + ++len; + } else { + --len; + break; + } + } + } else { + for (k = i + 2; k < x && len < 128; ++k) { + if (!memcmp(begin, row + k * comp, comp)) { + ++len; + } else { + break; + } + } + } + } + + if (diff) { + unsigned char header = STBIW_UCHAR(len - 1); + stbiw__write1(s, header); + for (k = 0; k < len; ++k) { + stbiw__write_pixel(s, -1, comp, has_alpha, 0, begin + k * comp); + } + } else { + unsigned char header = STBIW_UCHAR(len - 129); + stbiw__write1(s, header); + stbiw__write_pixel(s, -1, comp, has_alpha, 0, begin); + } + } + } + stbiw__write_flush(s); + } + return 1; +} + +STBIWDEF int stbi_write_tga_to_func(stbi_write_func *func, void *context, int x, int y, int comp, const void *data) +{ + stbi__write_context s = { 0 }; + stbi__start_write_callbacks(&s, func, context); + return stbi_write_tga_core(&s, x, y, comp, (void *) data); +} + +#ifndef STBI_WRITE_NO_STDIO +STBIWDEF int stbi_write_tga(char const *filename, int x, int y, int comp, const void *data) +{ + stbi__write_context s = { 0 }; + if (stbi__start_write_file(&s,filename)) { + int r = stbi_write_tga_core(&s, x, y, comp, (void *) data); + stbi__end_write_file(&s); + return r; + } else + return 0; +} +#endif + +// ************************************************************************************************* +// Radiance RGBE HDR writer +// by Baldur Karlsson + +#define stbiw__max(a, b) ((a) > (b) ? (a) : (b)) + +#ifndef STBI_WRITE_NO_STDIO + +static void stbiw__linear_to_rgbe(unsigned char *rgbe, float *linear) +{ + int exponent; + float maxcomp = stbiw__max(linear[0], stbiw__max(linear[1], linear[2])); + + if (maxcomp < 1e-32f) { + rgbe[0] = rgbe[1] = rgbe[2] = rgbe[3] = 0; + } else { + float normalize = (float) frexp(maxcomp, &exponent) * 256.0f/maxcomp; + + rgbe[0] = (unsigned char)(linear[0] * normalize); + rgbe[1] = (unsigned char)(linear[1] * normalize); + rgbe[2] = (unsigned char)(linear[2] * normalize); + rgbe[3] = (unsigned char)(exponent + 128); + } +} + +static void stbiw__write_run_data(stbi__write_context *s, int length, unsigned char databyte) +{ + unsigned char lengthbyte = STBIW_UCHAR(length+128); + STBIW_ASSERT(length+128 <= 255); + s->func(s->context, &lengthbyte, 1); + s->func(s->context, &databyte, 1); +} + +static void stbiw__write_dump_data(stbi__write_context *s, int length, unsigned char *data) +{ + unsigned char lengthbyte = STBIW_UCHAR(length); + STBIW_ASSERT(length <= 128); // inconsistent with spec but consistent with official code + s->func(s->context, &lengthbyte, 1); + s->func(s->context, data, length); +} + +static void stbiw__write_hdr_scanline(stbi__write_context *s, int width, int ncomp, unsigned char *scratch, float *scanline) +{ + unsigned char scanlineheader[4] = { 2, 2, 0, 0 }; + unsigned char rgbe[4]; + float linear[3]; + int x; + + scanlineheader[2] = (width&0xff00)>>8; + scanlineheader[3] = (width&0x00ff); + + /* skip RLE for images too small or large */ + if (width < 8 || width >= 32768) { + for (x=0; x < width; x++) { + switch (ncomp) { + case 4: /* fallthrough */ + case 3: linear[2] = scanline[x*ncomp + 2]; + linear[1] = scanline[x*ncomp + 1]; + linear[0] = scanline[x*ncomp + 0]; + break; + default: + linear[0] = linear[1] = linear[2] = scanline[x*ncomp + 0]; + break; + } + stbiw__linear_to_rgbe(rgbe, linear); + s->func(s->context, rgbe, 4); + } + } else { + int c,r; + /* encode into scratch buffer */ + for (x=0; x < width; x++) { + switch(ncomp) { + case 4: /* fallthrough */ + case 3: linear[2] = scanline[x*ncomp + 2]; + linear[1] = scanline[x*ncomp + 1]; + linear[0] = scanline[x*ncomp + 0]; + break; + default: + linear[0] = linear[1] = linear[2] = scanline[x*ncomp + 0]; + break; + } + stbiw__linear_to_rgbe(rgbe, linear); + scratch[x + width*0] = rgbe[0]; + scratch[x + width*1] = rgbe[1]; + scratch[x + width*2] = rgbe[2]; + scratch[x + width*3] = rgbe[3]; + } + + s->func(s->context, scanlineheader, 4); + + /* RLE each component separately */ + for (c=0; c < 4; c++) { + unsigned char *comp = &scratch[width*c]; + + x = 0; + while (x < width) { + // find first run + r = x; + while (r+2 < width) { + if (comp[r] == comp[r+1] && comp[r] == comp[r+2]) + break; + ++r; + } + if (r+2 >= width) + r = width; + // dump up to first run + while (x < r) { + int len = r-x; + if (len > 128) len = 128; + stbiw__write_dump_data(s, len, &comp[x]); + x += len; + } + // if there's a run, output it + if (r+2 < width) { // same test as what we break out of in search loop, so only true if we break'd + // find next byte after run + while (r < width && comp[r] == comp[x]) + ++r; + // output run up to r + while (x < r) { + int len = r-x; + if (len > 127) len = 127; + stbiw__write_run_data(s, len, comp[x]); + x += len; + } + } + } + } + } +} + +static int stbi_write_hdr_core(stbi__write_context *s, int x, int y, int comp, float *data) +{ + if (y <= 0 || x <= 0 || data == NULL) + return 0; + else { + // Each component is stored separately. Allocate scratch space for full output scanline. + unsigned char *scratch = (unsigned char *) STBIW_MALLOC(x*4); + int i, len; + char buffer[128]; + char header[] = "#?RADIANCE\n# Written by stb_image_write.h\nFORMAT=32-bit_rle_rgbe\n"; + s->func(s->context, header, sizeof(header)-1); + +#ifdef __STDC_LIB_EXT1__ + len = sprintf_s(buffer, sizeof(buffer), "EXPOSURE= 1.0000000000000\n\n-Y %d +X %d\n", y, x); +#else + len = sprintf(buffer, "EXPOSURE= 1.0000000000000\n\n-Y %d +X %d\n", y, x); +#endif + s->func(s->context, buffer, len); + + for(i=0; i < y; i++) + stbiw__write_hdr_scanline(s, x, comp, scratch, data + comp*x*(stbi__flip_vertically_on_write ? y-1-i : i)); + STBIW_FREE(scratch); + return 1; + } +} + +STBIWDEF int stbi_write_hdr_to_func(stbi_write_func *func, void *context, int x, int y, int comp, const float *data) +{ + stbi__write_context s = { 0 }; + stbi__start_write_callbacks(&s, func, context); + return stbi_write_hdr_core(&s, x, y, comp, (float *) data); +} + +STBIWDEF int stbi_write_hdr(char const *filename, int x, int y, int comp, const float *data) +{ + stbi__write_context s = { 0 }; + if (stbi__start_write_file(&s,filename)) { + int r = stbi_write_hdr_core(&s, x, y, comp, (float *) data); + stbi__end_write_file(&s); + return r; + } else + return 0; +} +#endif // STBI_WRITE_NO_STDIO + + +////////////////////////////////////////////////////////////////////////////// +// +// PNG writer +// + +#ifndef STBIW_ZLIB_COMPRESS +// stretchy buffer; stbiw__sbpush() == vector<>::push_back() -- stbiw__sbcount() == vector<>::size() +#define stbiw__sbraw(a) ((int *) (void *) (a) - 2) +#define stbiw__sbm(a) stbiw__sbraw(a)[0] +#define stbiw__sbn(a) stbiw__sbraw(a)[1] + +#define stbiw__sbneedgrow(a,n) ((a)==0 || stbiw__sbn(a)+n >= stbiw__sbm(a)) +#define stbiw__sbmaybegrow(a,n) (stbiw__sbneedgrow(a,(n)) ? stbiw__sbgrow(a,n) : 0) +#define stbiw__sbgrow(a,n) stbiw__sbgrowf((void **) &(a), (n), sizeof(*(a))) + +#define stbiw__sbpush(a, v) (stbiw__sbmaybegrow(a,1), (a)[stbiw__sbn(a)++] = (v)) +#define stbiw__sbcount(a) ((a) ? stbiw__sbn(a) : 0) +#define stbiw__sbfree(a) ((a) ? STBIW_FREE(stbiw__sbraw(a)),0 : 0) + +static void *stbiw__sbgrowf(void **arr, int increment, int itemsize) +{ + int m = *arr ? 2*stbiw__sbm(*arr)+increment : increment+1; + void *p = STBIW_REALLOC_SIZED(*arr ? stbiw__sbraw(*arr) : 0, *arr ? (stbiw__sbm(*arr)*itemsize + sizeof(int)*2) : 0, itemsize * m + sizeof(int)*2); + STBIW_ASSERT(p); + if (p) { + if (!*arr) ((int *) p)[1] = 0; + *arr = (void *) ((int *) p + 2); + stbiw__sbm(*arr) = m; + } + return *arr; +} + +static unsigned char *stbiw__zlib_flushf(unsigned char *data, unsigned int *bitbuffer, int *bitcount) +{ + while (*bitcount >= 8) { + stbiw__sbpush(data, STBIW_UCHAR(*bitbuffer)); + *bitbuffer >>= 8; + *bitcount -= 8; + } + return data; +} + +static int stbiw__zlib_bitrev(int code, int codebits) +{ + int res=0; + while (codebits--) { + res = (res << 1) | (code & 1); + code >>= 1; + } + return res; +} + +static unsigned int stbiw__zlib_countm(unsigned char *a, unsigned char *b, int limit) +{ + int i; + for (i=0; i < limit && i < 258; ++i) + if (a[i] != b[i]) break; + return i; +} + +static unsigned int stbiw__zhash(unsigned char *data) +{ + stbiw_uint32 hash = data[0] + (data[1] << 8) + (data[2] << 16); + hash ^= hash << 3; + hash += hash >> 5; + hash ^= hash << 4; + hash += hash >> 17; + hash ^= hash << 25; + hash += hash >> 6; + return hash; +} + +#define stbiw__zlib_flush() (out = stbiw__zlib_flushf(out, &bitbuf, &bitcount)) +#define stbiw__zlib_add(code,codebits) \ + (bitbuf |= (code) << bitcount, bitcount += (codebits), stbiw__zlib_flush()) +#define stbiw__zlib_huffa(b,c) stbiw__zlib_add(stbiw__zlib_bitrev(b,c),c) +// default huffman tables +#define stbiw__zlib_huff1(n) stbiw__zlib_huffa(0x30 + (n), 8) +#define stbiw__zlib_huff2(n) stbiw__zlib_huffa(0x190 + (n)-144, 9) +#define stbiw__zlib_huff3(n) stbiw__zlib_huffa(0 + (n)-256,7) +#define stbiw__zlib_huff4(n) stbiw__zlib_huffa(0xc0 + (n)-280,8) +#define stbiw__zlib_huff(n) ((n) <= 143 ? stbiw__zlib_huff1(n) : (n) <= 255 ? stbiw__zlib_huff2(n) : (n) <= 279 ? stbiw__zlib_huff3(n) : stbiw__zlib_huff4(n)) +#define stbiw__zlib_huffb(n) ((n) <= 143 ? stbiw__zlib_huff1(n) : stbiw__zlib_huff2(n)) + +#define stbiw__ZHASH 16384 + +#endif // STBIW_ZLIB_COMPRESS + +STBIWDEF unsigned char * stbi_zlib_compress(unsigned char *data, int data_len, int *out_len, int quality) +{ +#ifdef STBIW_ZLIB_COMPRESS + // user provided a zlib compress implementation, use that + return STBIW_ZLIB_COMPRESS(data, data_len, out_len, quality); +#else // use builtin + static unsigned short lengthc[] = { 3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258, 259 }; + static unsigned char lengtheb[]= { 0,0,0,0,0,0,0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0 }; + static unsigned short distc[] = { 1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577, 32768 }; + static unsigned char disteb[] = { 0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13 }; + unsigned int bitbuf=0; + int i,j, bitcount=0; + unsigned char *out = NULL; + unsigned char ***hash_table = (unsigned char***) STBIW_MALLOC(stbiw__ZHASH * sizeof(unsigned char**)); + if (hash_table == NULL) + return NULL; + if (quality < 5) quality = 5; + + stbiw__sbpush(out, 0x78); // DEFLATE 32K window + stbiw__sbpush(out, 0x5e); // FLEVEL = 1 + stbiw__zlib_add(1,1); // BFINAL = 1 + stbiw__zlib_add(1,2); // BTYPE = 1 -- fixed huffman + + for (i=0; i < stbiw__ZHASH; ++i) + hash_table[i] = NULL; + + i=0; + while (i < data_len-3) { + // hash next 3 bytes of data to be compressed + int h = stbiw__zhash(data+i)&(stbiw__ZHASH-1), best=3; + unsigned char *bestloc = 0; + unsigned char **hlist = hash_table[h]; + int n = stbiw__sbcount(hlist); + for (j=0; j < n; ++j) { + if (hlist[j]-data > i-32768) { // if entry lies within window + int d = stbiw__zlib_countm(hlist[j], data+i, data_len-i); + if (d >= best) { best=d; bestloc=hlist[j]; } + } + } + // when hash table entry is too long, delete half the entries + if (hash_table[h] && stbiw__sbn(hash_table[h]) == 2*quality) { + STBIW_MEMMOVE(hash_table[h], hash_table[h]+quality, sizeof(hash_table[h][0])*quality); + stbiw__sbn(hash_table[h]) = quality; + } + stbiw__sbpush(hash_table[h],data+i); + + if (bestloc) { + // "lazy matching" - check match at *next* byte, and if it's better, do cur byte as literal + h = stbiw__zhash(data+i+1)&(stbiw__ZHASH-1); + hlist = hash_table[h]; + n = stbiw__sbcount(hlist); + for (j=0; j < n; ++j) { + if (hlist[j]-data > i-32767) { + int e = stbiw__zlib_countm(hlist[j], data+i+1, data_len-i-1); + if (e > best) { // if next match is better, bail on current match + bestloc = NULL; + break; + } + } + } + } + + if (bestloc) { + int d = (int) (data+i - bestloc); // distance back + STBIW_ASSERT(d <= 32767 && best <= 258); + for (j=0; best > lengthc[j+1]-1; ++j); + stbiw__zlib_huff(j+257); + if (lengtheb[j]) stbiw__zlib_add(best - lengthc[j], lengtheb[j]); + for (j=0; d > distc[j+1]-1; ++j); + stbiw__zlib_add(stbiw__zlib_bitrev(j,5),5); + if (disteb[j]) stbiw__zlib_add(d - distc[j], disteb[j]); + i += best; + } else { + stbiw__zlib_huffb(data[i]); + ++i; + } + } + // write out final bytes + for (;i < data_len; ++i) + stbiw__zlib_huffb(data[i]); + stbiw__zlib_huff(256); // end of block + // pad with 0 bits to byte boundary + while (bitcount) + stbiw__zlib_add(0,1); + + for (i=0; i < stbiw__ZHASH; ++i) + (void) stbiw__sbfree(hash_table[i]); + STBIW_FREE(hash_table); + + // store uncompressed instead if compression was worse + if (stbiw__sbn(out) > data_len + 2 + ((data_len+32766)/32767)*5) { + stbiw__sbn(out) = 2; // truncate to DEFLATE 32K window and FLEVEL = 1 + for (j = 0; j < data_len;) { + int blocklen = data_len - j; + if (blocklen > 32767) blocklen = 32767; + stbiw__sbpush(out, data_len - j == blocklen); // BFINAL = ?, BTYPE = 0 -- no compression + stbiw__sbpush(out, STBIW_UCHAR(blocklen)); // LEN + stbiw__sbpush(out, STBIW_UCHAR(blocklen >> 8)); + stbiw__sbpush(out, STBIW_UCHAR(~blocklen)); // NLEN + stbiw__sbpush(out, STBIW_UCHAR(~blocklen >> 8)); + memcpy(out+stbiw__sbn(out), data+j, blocklen); + stbiw__sbn(out) += blocklen; + j += blocklen; + } + } + + { + // compute adler32 on input + unsigned int s1=1, s2=0; + int blocklen = (int) (data_len % 5552); + j=0; + while (j < data_len) { + for (i=0; i < blocklen; ++i) { s1 += data[j+i]; s2 += s1; } + s1 %= 65521; s2 %= 65521; + j += blocklen; + blocklen = 5552; + } + stbiw__sbpush(out, STBIW_UCHAR(s2 >> 8)); + stbiw__sbpush(out, STBIW_UCHAR(s2)); + stbiw__sbpush(out, STBIW_UCHAR(s1 >> 8)); + stbiw__sbpush(out, STBIW_UCHAR(s1)); + } + *out_len = stbiw__sbn(out); + // make returned pointer freeable + STBIW_MEMMOVE(stbiw__sbraw(out), out, *out_len); + return (unsigned char *) stbiw__sbraw(out); +#endif // STBIW_ZLIB_COMPRESS +} + +static unsigned int stbiw__crc32(unsigned char *buffer, int len) +{ +#ifdef STBIW_CRC32 + return STBIW_CRC32(buffer, len); +#else + static unsigned int crc_table[256] = + { + 0x00000000, 0x77073096, 0xEE0E612C, 0x990951BA, 0x076DC419, 0x706AF48F, 0xE963A535, 0x9E6495A3, + 0x0eDB8832, 0x79DCB8A4, 0xE0D5E91E, 0x97D2D988, 0x09B64C2B, 0x7EB17CBD, 0xE7B82D07, 0x90BF1D91, + 0x1DB71064, 0x6AB020F2, 0xF3B97148, 0x84BE41DE, 0x1ADAD47D, 0x6DDDE4EB, 0xF4D4B551, 0x83D385C7, + 0x136C9856, 0x646BA8C0, 0xFD62F97A, 0x8A65C9EC, 0x14015C4F, 0x63066CD9, 0xFA0F3D63, 0x8D080DF5, + 0x3B6E20C8, 0x4C69105E, 0xD56041E4, 0xA2677172, 0x3C03E4D1, 0x4B04D447, 0xD20D85FD, 0xA50AB56B, + 0x35B5A8FA, 0x42B2986C, 0xDBBBC9D6, 0xACBCF940, 0x32D86CE3, 0x45DF5C75, 0xDCD60DCF, 0xABD13D59, + 0x26D930AC, 0x51DE003A, 0xC8D75180, 0xBFD06116, 0x21B4F4B5, 0x56B3C423, 0xCFBA9599, 0xB8BDA50F, + 0x2802B89E, 0x5F058808, 0xC60CD9B2, 0xB10BE924, 0x2F6F7C87, 0x58684C11, 0xC1611DAB, 0xB6662D3D, + 0x76DC4190, 0x01DB7106, 0x98D220BC, 0xEFD5102A, 0x71B18589, 0x06B6B51F, 0x9FBFE4A5, 0xE8B8D433, + 0x7807C9A2, 0x0F00F934, 0x9609A88E, 0xE10E9818, 0x7F6A0DBB, 0x086D3D2D, 0x91646C97, 0xE6635C01, + 0x6B6B51F4, 0x1C6C6162, 0x856530D8, 0xF262004E, 0x6C0695ED, 0x1B01A57B, 0x8208F4C1, 0xF50FC457, + 0x65B0D9C6, 0x12B7E950, 0x8BBEB8EA, 0xFCB9887C, 0x62DD1DDF, 0x15DA2D49, 0x8CD37CF3, 0xFBD44C65, + 0x4DB26158, 0x3AB551CE, 0xA3BC0074, 0xD4BB30E2, 0x4ADFA541, 0x3DD895D7, 0xA4D1C46D, 0xD3D6F4FB, + 0x4369E96A, 0x346ED9FC, 0xAD678846, 0xDA60B8D0, 0x44042D73, 0x33031DE5, 0xAA0A4C5F, 0xDD0D7CC9, + 0x5005713C, 0x270241AA, 0xBE0B1010, 0xC90C2086, 0x5768B525, 0x206F85B3, 0xB966D409, 0xCE61E49F, + 0x5EDEF90E, 0x29D9C998, 0xB0D09822, 0xC7D7A8B4, 0x59B33D17, 0x2EB40D81, 0xB7BD5C3B, 0xC0BA6CAD, + 0xEDB88320, 0x9ABFB3B6, 0x03B6E20C, 0x74B1D29A, 0xEAD54739, 0x9DD277AF, 0x04DB2615, 0x73DC1683, + 0xE3630B12, 0x94643B84, 0x0D6D6A3E, 0x7A6A5AA8, 0xE40ECF0B, 0x9309FF9D, 0x0A00AE27, 0x7D079EB1, + 0xF00F9344, 0x8708A3D2, 0x1E01F268, 0x6906C2FE, 0xF762575D, 0x806567CB, 0x196C3671, 0x6E6B06E7, + 0xFED41B76, 0x89D32BE0, 0x10DA7A5A, 0x67DD4ACC, 0xF9B9DF6F, 0x8EBEEFF9, 0x17B7BE43, 0x60B08ED5, + 0xD6D6A3E8, 0xA1D1937E, 0x38D8C2C4, 0x4FDFF252, 0xD1BB67F1, 0xA6BC5767, 0x3FB506DD, 0x48B2364B, + 0xD80D2BDA, 0xAF0A1B4C, 0x36034AF6, 0x41047A60, 0xDF60EFC3, 0xA867DF55, 0x316E8EEF, 0x4669BE79, + 0xCB61B38C, 0xBC66831A, 0x256FD2A0, 0x5268E236, 0xCC0C7795, 0xBB0B4703, 0x220216B9, 0x5505262F, + 0xC5BA3BBE, 0xB2BD0B28, 0x2BB45A92, 0x5CB36A04, 0xC2D7FFA7, 0xB5D0CF31, 0x2CD99E8B, 0x5BDEAE1D, + 0x9B64C2B0, 0xEC63F226, 0x756AA39C, 0x026D930A, 0x9C0906A9, 0xEB0E363F, 0x72076785, 0x05005713, + 0x95BF4A82, 0xE2B87A14, 0x7BB12BAE, 0x0CB61B38, 0x92D28E9B, 0xE5D5BE0D, 0x7CDCEFB7, 0x0BDBDF21, + 0x86D3D2D4, 0xF1D4E242, 0x68DDB3F8, 0x1FDA836E, 0x81BE16CD, 0xF6B9265B, 0x6FB077E1, 0x18B74777, + 0x88085AE6, 0xFF0F6A70, 0x66063BCA, 0x11010B5C, 0x8F659EFF, 0xF862AE69, 0x616BFFD3, 0x166CCF45, + 0xA00AE278, 0xD70DD2EE, 0x4E048354, 0x3903B3C2, 0xA7672661, 0xD06016F7, 0x4969474D, 0x3E6E77DB, + 0xAED16A4A, 0xD9D65ADC, 0x40DF0B66, 0x37D83BF0, 0xA9BCAE53, 0xDEBB9EC5, 0x47B2CF7F, 0x30B5FFE9, + 0xBDBDF21C, 0xCABAC28A, 0x53B39330, 0x24B4A3A6, 0xBAD03605, 0xCDD70693, 0x54DE5729, 0x23D967BF, + 0xB3667A2E, 0xC4614AB8, 0x5D681B02, 0x2A6F2B94, 0xB40BBE37, 0xC30C8EA1, 0x5A05DF1B, 0x2D02EF8D + }; + + unsigned int crc = ~0u; + int i; + for (i=0; i < len; ++i) + crc = (crc >> 8) ^ crc_table[buffer[i] ^ (crc & 0xff)]; + return ~crc; +#endif +} + +#define stbiw__wpng4(o,a,b,c,d) ((o)[0]=STBIW_UCHAR(a),(o)[1]=STBIW_UCHAR(b),(o)[2]=STBIW_UCHAR(c),(o)[3]=STBIW_UCHAR(d),(o)+=4) +#define stbiw__wp32(data,v) stbiw__wpng4(data, (v)>>24,(v)>>16,(v)>>8,(v)); +#define stbiw__wptag(data,s) stbiw__wpng4(data, s[0],s[1],s[2],s[3]) + +static void stbiw__wpcrc(unsigned char **data, int len) +{ + unsigned int crc = stbiw__crc32(*data - len - 4, len+4); + stbiw__wp32(*data, crc); +} + +static unsigned char stbiw__paeth(int a, int b, int c) +{ + int p = a + b - c, pa = abs(p-a), pb = abs(p-b), pc = abs(p-c); + if (pa <= pb && pa <= pc) return STBIW_UCHAR(a); + if (pb <= pc) return STBIW_UCHAR(b); + return STBIW_UCHAR(c); +} + +// @OPTIMIZE: provide an option that always forces left-predict or paeth predict +static void stbiw__encode_png_line(unsigned char *pixels, int stride_bytes, int width, int height, int y, int n, int filter_type, signed char *line_buffer) +{ + static int mapping[] = { 0,1,2,3,4 }; + static int firstmap[] = { 0,1,0,5,6 }; + int *mymap = (y != 0) ? mapping : firstmap; + int i; + int type = mymap[filter_type]; + unsigned char *z = pixels + stride_bytes * (stbi__flip_vertically_on_write ? height-1-y : y); + int signed_stride = stbi__flip_vertically_on_write ? -stride_bytes : stride_bytes; + + if (type==0) { + memcpy(line_buffer, z, width*n); + return; + } + + // first loop isn't optimized since it's just one pixel + for (i = 0; i < n; ++i) { + switch (type) { + case 1: line_buffer[i] = z[i]; break; + case 2: line_buffer[i] = z[i] - z[i-signed_stride]; break; + case 3: line_buffer[i] = z[i] - (z[i-signed_stride]>>1); break; + case 4: line_buffer[i] = (signed char) (z[i] - stbiw__paeth(0,z[i-signed_stride],0)); break; + case 5: line_buffer[i] = z[i]; break; + case 6: line_buffer[i] = z[i]; break; + } + } + switch (type) { + case 1: for (i=n; i < width*n; ++i) line_buffer[i] = z[i] - z[i-n]; break; + case 2: for (i=n; i < width*n; ++i) line_buffer[i] = z[i] - z[i-signed_stride]; break; + case 3: for (i=n; i < width*n; ++i) line_buffer[i] = z[i] - ((z[i-n] + z[i-signed_stride])>>1); break; + case 4: for (i=n; i < width*n; ++i) line_buffer[i] = z[i] - stbiw__paeth(z[i-n], z[i-signed_stride], z[i-signed_stride-n]); break; + case 5: for (i=n; i < width*n; ++i) line_buffer[i] = z[i] - (z[i-n]>>1); break; + case 6: for (i=n; i < width*n; ++i) line_buffer[i] = z[i] - stbiw__paeth(z[i-n], 0,0); break; + } +} + +STBIWDEF unsigned char *stbi_write_png_to_mem(const unsigned char *pixels, int stride_bytes, int x, int y, int n, int *out_len) +{ + int force_filter = stbi_write_force_png_filter; + int ctype[5] = { -1, 0, 4, 2, 6 }; + unsigned char sig[8] = { 137,80,78,71,13,10,26,10 }; + unsigned char *out,*o, *filt, *zlib; + signed char *line_buffer; + int j,zlen; + + if (stride_bytes == 0) + stride_bytes = x * n; + + if (force_filter >= 5) { + force_filter = -1; + } + + filt = (unsigned char *) STBIW_MALLOC((x*n+1) * y); if (!filt) return 0; + line_buffer = (signed char *) STBIW_MALLOC(x * n); if (!line_buffer) { STBIW_FREE(filt); return 0; } + for (j=0; j < y; ++j) { + int filter_type; + if (force_filter > -1) { + filter_type = force_filter; + stbiw__encode_png_line((unsigned char*)(pixels), stride_bytes, x, y, j, n, force_filter, line_buffer); + } else { // Estimate the best filter by running through all of them: + int best_filter = 0, best_filter_val = 0x7fffffff, est, i; + for (filter_type = 0; filter_type < 5; filter_type++) { + stbiw__encode_png_line((unsigned char*)(pixels), stride_bytes, x, y, j, n, filter_type, line_buffer); + + // Estimate the entropy of the line using this filter; the less, the better. + est = 0; + for (i = 0; i < x*n; ++i) { + est += abs((signed char) line_buffer[i]); + } + if (est < best_filter_val) { + best_filter_val = est; + best_filter = filter_type; + } + } + if (filter_type != best_filter) { // If the last iteration already got us the best filter, don't redo it + stbiw__encode_png_line((unsigned char*)(pixels), stride_bytes, x, y, j, n, best_filter, line_buffer); + filter_type = best_filter; + } + } + // when we get here, filter_type contains the filter type, and line_buffer contains the data + filt[j*(x*n+1)] = (unsigned char) filter_type; + STBIW_MEMMOVE(filt+j*(x*n+1)+1, line_buffer, x*n); + } + STBIW_FREE(line_buffer); + zlib = stbi_zlib_compress(filt, y*( x*n+1), &zlen, stbi_write_png_compression_level); + STBIW_FREE(filt); + if (!zlib) return 0; + + // each tag requires 12 bytes of overhead + out = (unsigned char *) STBIW_MALLOC(8 + 12+13 + 12+zlen + 12); + if (!out) return 0; + *out_len = 8 + 12+13 + 12+zlen + 12; + + o=out; + STBIW_MEMMOVE(o,sig,8); o+= 8; + stbiw__wp32(o, 13); // header length + stbiw__wptag(o, "IHDR"); + stbiw__wp32(o, x); + stbiw__wp32(o, y); + *o++ = 8; + *o++ = STBIW_UCHAR(ctype[n]); + *o++ = 0; + *o++ = 0; + *o++ = 0; + stbiw__wpcrc(&o,13); + + stbiw__wp32(o, zlen); + stbiw__wptag(o, "IDAT"); + STBIW_MEMMOVE(o, zlib, zlen); + o += zlen; + STBIW_FREE(zlib); + stbiw__wpcrc(&o, zlen); + + stbiw__wp32(o,0); + stbiw__wptag(o, "IEND"); + stbiw__wpcrc(&o,0); + + STBIW_ASSERT(o == out + *out_len); + + return out; +} + +#ifndef STBI_WRITE_NO_STDIO +STBIWDEF int stbi_write_png(char const *filename, int x, int y, int comp, const void *data, int stride_bytes) +{ + FILE *f; + int len; + unsigned char *png = stbi_write_png_to_mem((const unsigned char *) data, stride_bytes, x, y, comp, &len); + if (png == NULL) return 0; + + f = stbiw__fopen(filename, "wb"); + if (!f) { STBIW_FREE(png); return 0; } + fwrite(png, 1, len, f); + fclose(f); + STBIW_FREE(png); + return 1; +} +#endif + +STBIWDEF int stbi_write_png_to_func(stbi_write_func *func, void *context, int x, int y, int comp, const void *data, int stride_bytes) +{ + int len; + unsigned char *png = stbi_write_png_to_mem((const unsigned char *) data, stride_bytes, x, y, comp, &len); + if (png == NULL) return 0; + func(context, png, len); + STBIW_FREE(png); + return 1; +} + + +/* *************************************************************************** + * + * JPEG writer + * + * This is based on Jon Olick's jo_jpeg.cpp: + * public domain Simple, Minimalistic JPEG writer - http://www.jonolick.com/code.html + */ + +static const unsigned char stbiw__jpg_ZigZag[] = { 0,1,5,6,14,15,27,28,2,4,7,13,16,26,29,42,3,8,12,17,25,30,41,43,9,11,18, + 24,31,40,44,53,10,19,23,32,39,45,52,54,20,22,33,38,46,51,55,60,21,34,37,47,50,56,59,61,35,36,48,49,57,58,62,63 }; + +static void stbiw__jpg_writeBits(stbi__write_context *s, int *bitBufP, int *bitCntP, const unsigned short *bs) { + int bitBuf = *bitBufP, bitCnt = *bitCntP; + bitCnt += bs[1]; + bitBuf |= bs[0] << (24 - bitCnt); + while(bitCnt >= 8) { + unsigned char c = (bitBuf >> 16) & 255; + stbiw__putc(s, c); + if(c == 255) { + stbiw__putc(s, 0); + } + bitBuf <<= 8; + bitCnt -= 8; + } + *bitBufP = bitBuf; + *bitCntP = bitCnt; +} + +static void stbiw__jpg_DCT(float *d0p, float *d1p, float *d2p, float *d3p, float *d4p, float *d5p, float *d6p, float *d7p) { + float d0 = *d0p, d1 = *d1p, d2 = *d2p, d3 = *d3p, d4 = *d4p, d5 = *d5p, d6 = *d6p, d7 = *d7p; + float z1, z2, z3, z4, z5, z11, z13; + + float tmp0 = d0 + d7; + float tmp7 = d0 - d7; + float tmp1 = d1 + d6; + float tmp6 = d1 - d6; + float tmp2 = d2 + d5; + float tmp5 = d2 - d5; + float tmp3 = d3 + d4; + float tmp4 = d3 - d4; + + // Even part + float tmp10 = tmp0 + tmp3; // phase 2 + float tmp13 = tmp0 - tmp3; + float tmp11 = tmp1 + tmp2; + float tmp12 = tmp1 - tmp2; + + d0 = tmp10 + tmp11; // phase 3 + d4 = tmp10 - tmp11; + + z1 = (tmp12 + tmp13) * 0.707106781f; // c4 + d2 = tmp13 + z1; // phase 5 + d6 = tmp13 - z1; + + // Odd part + tmp10 = tmp4 + tmp5; // phase 2 + tmp11 = tmp5 + tmp6; + tmp12 = tmp6 + tmp7; + + // The rotator is modified from fig 4-8 to avoid extra negations. + z5 = (tmp10 - tmp12) * 0.382683433f; // c6 + z2 = tmp10 * 0.541196100f + z5; // c2-c6 + z4 = tmp12 * 1.306562965f + z5; // c2+c6 + z3 = tmp11 * 0.707106781f; // c4 + + z11 = tmp7 + z3; // phase 5 + z13 = tmp7 - z3; + + *d5p = z13 + z2; // phase 6 + *d3p = z13 - z2; + *d1p = z11 + z4; + *d7p = z11 - z4; + + *d0p = d0; *d2p = d2; *d4p = d4; *d6p = d6; +} + +static void stbiw__jpg_calcBits(int val, unsigned short bits[2]) { + int tmp1 = val < 0 ? -val : val; + val = val < 0 ? val-1 : val; + bits[1] = 1; + while(tmp1 >>= 1) { + ++bits[1]; + } + bits[0] = val & ((1<0)&&(DU[end0pos]==0); --end0pos) { + } + // end0pos = first element in reverse order !=0 + if(end0pos == 0) { + stbiw__jpg_writeBits(s, bitBuf, bitCnt, EOB); + return DU[0]; + } + for(i = 1; i <= end0pos; ++i) { + int startpos = i; + int nrzeroes; + unsigned short bits[2]; + for (; DU[i]==0 && i<=end0pos; ++i) { + } + nrzeroes = i-startpos; + if ( nrzeroes >= 16 ) { + int lng = nrzeroes>>4; + int nrmarker; + for (nrmarker=1; nrmarker <= lng; ++nrmarker) + stbiw__jpg_writeBits(s, bitBuf, bitCnt, M16zeroes); + nrzeroes &= 15; + } + stbiw__jpg_calcBits(DU[i], bits); + stbiw__jpg_writeBits(s, bitBuf, bitCnt, HTAC[(nrzeroes<<4)+bits[1]]); + stbiw__jpg_writeBits(s, bitBuf, bitCnt, bits); + } + if(end0pos != 63) { + stbiw__jpg_writeBits(s, bitBuf, bitCnt, EOB); + } + return DU[0]; +} + +static int stbi_write_jpg_core(stbi__write_context *s, int width, int height, int comp, const void* data, int quality) { + // Constants that don't pollute global namespace + static const unsigned char std_dc_luminance_nrcodes[] = {0,0,1,5,1,1,1,1,1,1,0,0,0,0,0,0,0}; + static const unsigned char std_dc_luminance_values[] = {0,1,2,3,4,5,6,7,8,9,10,11}; + static const unsigned char std_ac_luminance_nrcodes[] = {0,0,2,1,3,3,2,4,3,5,5,4,4,0,0,1,0x7d}; + static const unsigned char std_ac_luminance_values[] = { + 0x01,0x02,0x03,0x00,0x04,0x11,0x05,0x12,0x21,0x31,0x41,0x06,0x13,0x51,0x61,0x07,0x22,0x71,0x14,0x32,0x81,0x91,0xa1,0x08, + 0x23,0x42,0xb1,0xc1,0x15,0x52,0xd1,0xf0,0x24,0x33,0x62,0x72,0x82,0x09,0x0a,0x16,0x17,0x18,0x19,0x1a,0x25,0x26,0x27,0x28, + 0x29,0x2a,0x34,0x35,0x36,0x37,0x38,0x39,0x3a,0x43,0x44,0x45,0x46,0x47,0x48,0x49,0x4a,0x53,0x54,0x55,0x56,0x57,0x58,0x59, + 0x5a,0x63,0x64,0x65,0x66,0x67,0x68,0x69,0x6a,0x73,0x74,0x75,0x76,0x77,0x78,0x79,0x7a,0x83,0x84,0x85,0x86,0x87,0x88,0x89, + 0x8a,0x92,0x93,0x94,0x95,0x96,0x97,0x98,0x99,0x9a,0xa2,0xa3,0xa4,0xa5,0xa6,0xa7,0xa8,0xa9,0xaa,0xb2,0xb3,0xb4,0xb5,0xb6, + 0xb7,0xb8,0xb9,0xba,0xc2,0xc3,0xc4,0xc5,0xc6,0xc7,0xc8,0xc9,0xca,0xd2,0xd3,0xd4,0xd5,0xd6,0xd7,0xd8,0xd9,0xda,0xe1,0xe2, + 0xe3,0xe4,0xe5,0xe6,0xe7,0xe8,0xe9,0xea,0xf1,0xf2,0xf3,0xf4,0xf5,0xf6,0xf7,0xf8,0xf9,0xfa + }; + static const unsigned char std_dc_chrominance_nrcodes[] = {0,0,3,1,1,1,1,1,1,1,1,1,0,0,0,0,0}; + static const unsigned char std_dc_chrominance_values[] = {0,1,2,3,4,5,6,7,8,9,10,11}; + static const unsigned char std_ac_chrominance_nrcodes[] = {0,0,2,1,2,4,4,3,4,7,5,4,4,0,1,2,0x77}; + static const unsigned char std_ac_chrominance_values[] = { + 0x00,0x01,0x02,0x03,0x11,0x04,0x05,0x21,0x31,0x06,0x12,0x41,0x51,0x07,0x61,0x71,0x13,0x22,0x32,0x81,0x08,0x14,0x42,0x91, + 0xa1,0xb1,0xc1,0x09,0x23,0x33,0x52,0xf0,0x15,0x62,0x72,0xd1,0x0a,0x16,0x24,0x34,0xe1,0x25,0xf1,0x17,0x18,0x19,0x1a,0x26, + 0x27,0x28,0x29,0x2a,0x35,0x36,0x37,0x38,0x39,0x3a,0x43,0x44,0x45,0x46,0x47,0x48,0x49,0x4a,0x53,0x54,0x55,0x56,0x57,0x58, + 0x59,0x5a,0x63,0x64,0x65,0x66,0x67,0x68,0x69,0x6a,0x73,0x74,0x75,0x76,0x77,0x78,0x79,0x7a,0x82,0x83,0x84,0x85,0x86,0x87, + 0x88,0x89,0x8a,0x92,0x93,0x94,0x95,0x96,0x97,0x98,0x99,0x9a,0xa2,0xa3,0xa4,0xa5,0xa6,0xa7,0xa8,0xa9,0xaa,0xb2,0xb3,0xb4, + 0xb5,0xb6,0xb7,0xb8,0xb9,0xba,0xc2,0xc3,0xc4,0xc5,0xc6,0xc7,0xc8,0xc9,0xca,0xd2,0xd3,0xd4,0xd5,0xd6,0xd7,0xd8,0xd9,0xda, + 0xe2,0xe3,0xe4,0xe5,0xe6,0xe7,0xe8,0xe9,0xea,0xf2,0xf3,0xf4,0xf5,0xf6,0xf7,0xf8,0xf9,0xfa + }; + // Huffman tables + static const unsigned short YDC_HT[256][2] = { {0,2},{2,3},{3,3},{4,3},{5,3},{6,3},{14,4},{30,5},{62,6},{126,7},{254,8},{510,9}}; + static const unsigned short UVDC_HT[256][2] = { {0,2},{1,2},{2,2},{6,3},{14,4},{30,5},{62,6},{126,7},{254,8},{510,9},{1022,10},{2046,11}}; + static const unsigned short YAC_HT[256][2] = { + {10,4},{0,2},{1,2},{4,3},{11,4},{26,5},{120,7},{248,8},{1014,10},{65410,16},{65411,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {12,4},{27,5},{121,7},{502,9},{2038,11},{65412,16},{65413,16},{65414,16},{65415,16},{65416,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {28,5},{249,8},{1015,10},{4084,12},{65417,16},{65418,16},{65419,16},{65420,16},{65421,16},{65422,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {58,6},{503,9},{4085,12},{65423,16},{65424,16},{65425,16},{65426,16},{65427,16},{65428,16},{65429,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {59,6},{1016,10},{65430,16},{65431,16},{65432,16},{65433,16},{65434,16},{65435,16},{65436,16},{65437,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {122,7},{2039,11},{65438,16},{65439,16},{65440,16},{65441,16},{65442,16},{65443,16},{65444,16},{65445,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {123,7},{4086,12},{65446,16},{65447,16},{65448,16},{65449,16},{65450,16},{65451,16},{65452,16},{65453,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {250,8},{4087,12},{65454,16},{65455,16},{65456,16},{65457,16},{65458,16},{65459,16},{65460,16},{65461,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {504,9},{32704,15},{65462,16},{65463,16},{65464,16},{65465,16},{65466,16},{65467,16},{65468,16},{65469,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {505,9},{65470,16},{65471,16},{65472,16},{65473,16},{65474,16},{65475,16},{65476,16},{65477,16},{65478,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {506,9},{65479,16},{65480,16},{65481,16},{65482,16},{65483,16},{65484,16},{65485,16},{65486,16},{65487,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {1017,10},{65488,16},{65489,16},{65490,16},{65491,16},{65492,16},{65493,16},{65494,16},{65495,16},{65496,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {1018,10},{65497,16},{65498,16},{65499,16},{65500,16},{65501,16},{65502,16},{65503,16},{65504,16},{65505,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {2040,11},{65506,16},{65507,16},{65508,16},{65509,16},{65510,16},{65511,16},{65512,16},{65513,16},{65514,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {65515,16},{65516,16},{65517,16},{65518,16},{65519,16},{65520,16},{65521,16},{65522,16},{65523,16},{65524,16},{0,0},{0,0},{0,0},{0,0},{0,0}, + {2041,11},{65525,16},{65526,16},{65527,16},{65528,16},{65529,16},{65530,16},{65531,16},{65532,16},{65533,16},{65534,16},{0,0},{0,0},{0,0},{0,0},{0,0} + }; + static const unsigned short UVAC_HT[256][2] = { + {0,2},{1,2},{4,3},{10,4},{24,5},{25,5},{56,6},{120,7},{500,9},{1014,10},{4084,12},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {11,4},{57,6},{246,8},{501,9},{2038,11},{4085,12},{65416,16},{65417,16},{65418,16},{65419,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {26,5},{247,8},{1015,10},{4086,12},{32706,15},{65420,16},{65421,16},{65422,16},{65423,16},{65424,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {27,5},{248,8},{1016,10},{4087,12},{65425,16},{65426,16},{65427,16},{65428,16},{65429,16},{65430,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {58,6},{502,9},{65431,16},{65432,16},{65433,16},{65434,16},{65435,16},{65436,16},{65437,16},{65438,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {59,6},{1017,10},{65439,16},{65440,16},{65441,16},{65442,16},{65443,16},{65444,16},{65445,16},{65446,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {121,7},{2039,11},{65447,16},{65448,16},{65449,16},{65450,16},{65451,16},{65452,16},{65453,16},{65454,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {122,7},{2040,11},{65455,16},{65456,16},{65457,16},{65458,16},{65459,16},{65460,16},{65461,16},{65462,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {249,8},{65463,16},{65464,16},{65465,16},{65466,16},{65467,16},{65468,16},{65469,16},{65470,16},{65471,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {503,9},{65472,16},{65473,16},{65474,16},{65475,16},{65476,16},{65477,16},{65478,16},{65479,16},{65480,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {504,9},{65481,16},{65482,16},{65483,16},{65484,16},{65485,16},{65486,16},{65487,16},{65488,16},{65489,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {505,9},{65490,16},{65491,16},{65492,16},{65493,16},{65494,16},{65495,16},{65496,16},{65497,16},{65498,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {506,9},{65499,16},{65500,16},{65501,16},{65502,16},{65503,16},{65504,16},{65505,16},{65506,16},{65507,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {2041,11},{65508,16},{65509,16},{65510,16},{65511,16},{65512,16},{65513,16},{65514,16},{65515,16},{65516,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0}, + {16352,14},{65517,16},{65518,16},{65519,16},{65520,16},{65521,16},{65522,16},{65523,16},{65524,16},{65525,16},{0,0},{0,0},{0,0},{0,0},{0,0}, + {1018,10},{32707,15},{65526,16},{65527,16},{65528,16},{65529,16},{65530,16},{65531,16},{65532,16},{65533,16},{65534,16},{0,0},{0,0},{0,0},{0,0},{0,0} + }; + static const int YQT[] = {16,11,10,16,24,40,51,61,12,12,14,19,26,58,60,55,14,13,16,24,40,57,69,56,14,17,22,29,51,87,80,62,18,22, + 37,56,68,109,103,77,24,35,55,64,81,104,113,92,49,64,78,87,103,121,120,101,72,92,95,98,112,100,103,99}; + static const int UVQT[] = {17,18,24,47,99,99,99,99,18,21,26,66,99,99,99,99,24,26,56,99,99,99,99,99,47,66,99,99,99,99,99,99, + 99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99}; + static const float aasf[] = { 1.0f * 2.828427125f, 1.387039845f * 2.828427125f, 1.306562965f * 2.828427125f, 1.175875602f * 2.828427125f, + 1.0f * 2.828427125f, 0.785694958f * 2.828427125f, 0.541196100f * 2.828427125f, 0.275899379f * 2.828427125f }; + + int row, col, i, k, subsample; + float fdtbl_Y[64], fdtbl_UV[64]; + unsigned char YTable[64], UVTable[64]; + + if(!data || !width || !height || comp > 4 || comp < 1) { + return 0; + } + + quality = quality ? quality : 90; + subsample = quality <= 90 ? 1 : 0; + quality = quality < 1 ? 1 : quality > 100 ? 100 : quality; + quality = quality < 50 ? 5000 / quality : 200 - quality * 2; + + for(i = 0; i < 64; ++i) { + int uvti, yti = (YQT[i]*quality+50)/100; + YTable[stbiw__jpg_ZigZag[i]] = (unsigned char) (yti < 1 ? 1 : yti > 255 ? 255 : yti); + uvti = (UVQT[i]*quality+50)/100; + UVTable[stbiw__jpg_ZigZag[i]] = (unsigned char) (uvti < 1 ? 1 : uvti > 255 ? 255 : uvti); + } + + for(row = 0, k = 0; row < 8; ++row) { + for(col = 0; col < 8; ++col, ++k) { + fdtbl_Y[k] = 1 / (YTable [stbiw__jpg_ZigZag[k]] * aasf[row] * aasf[col]); + fdtbl_UV[k] = 1 / (UVTable[stbiw__jpg_ZigZag[k]] * aasf[row] * aasf[col]); + } + } + + // Write Headers + { + static const unsigned char head0[] = { 0xFF,0xD8,0xFF,0xE0,0,0x10,'J','F','I','F',0,1,1,0,0,1,0,1,0,0,0xFF,0xDB,0,0x84,0 }; + static const unsigned char head2[] = { 0xFF,0xDA,0,0xC,3,1,0,2,0x11,3,0x11,0,0x3F,0 }; + const unsigned char head1[] = { 0xFF,0xC0,0,0x11,8,(unsigned char)(height>>8),STBIW_UCHAR(height),(unsigned char)(width>>8),STBIW_UCHAR(width), + 3,1,(unsigned char)(subsample?0x22:0x11),0,2,0x11,1,3,0x11,1,0xFF,0xC4,0x01,0xA2,0 }; + s->func(s->context, (void*)head0, sizeof(head0)); + s->func(s->context, (void*)YTable, sizeof(YTable)); + stbiw__putc(s, 1); + s->func(s->context, UVTable, sizeof(UVTable)); + s->func(s->context, (void*)head1, sizeof(head1)); + s->func(s->context, (void*)(std_dc_luminance_nrcodes+1), sizeof(std_dc_luminance_nrcodes)-1); + s->func(s->context, (void*)std_dc_luminance_values, sizeof(std_dc_luminance_values)); + stbiw__putc(s, 0x10); // HTYACinfo + s->func(s->context, (void*)(std_ac_luminance_nrcodes+1), sizeof(std_ac_luminance_nrcodes)-1); + s->func(s->context, (void*)std_ac_luminance_values, sizeof(std_ac_luminance_values)); + stbiw__putc(s, 1); // HTUDCinfo + s->func(s->context, (void*)(std_dc_chrominance_nrcodes+1), sizeof(std_dc_chrominance_nrcodes)-1); + s->func(s->context, (void*)std_dc_chrominance_values, sizeof(std_dc_chrominance_values)); + stbiw__putc(s, 0x11); // HTUACinfo + s->func(s->context, (void*)(std_ac_chrominance_nrcodes+1), sizeof(std_ac_chrominance_nrcodes)-1); + s->func(s->context, (void*)std_ac_chrominance_values, sizeof(std_ac_chrominance_values)); + s->func(s->context, (void*)head2, sizeof(head2)); + } + + // Encode 8x8 macroblocks + { + static const unsigned short fillBits[] = {0x7F, 7}; + int DCY=0, DCU=0, DCV=0; + int bitBuf=0, bitCnt=0; + // comp == 2 is grey+alpha (alpha is ignored) + int ofsG = comp > 2 ? 1 : 0, ofsB = comp > 2 ? 2 : 0; + const unsigned char *dataR = (const unsigned char *)data; + const unsigned char *dataG = dataR + ofsG; + const unsigned char *dataB = dataR + ofsB; + int x, y, pos; + if(subsample) { + for(y = 0; y < height; y += 16) { + for(x = 0; x < width; x += 16) { + float Y[256], U[256], V[256]; + for(row = y, pos = 0; row < y+16; ++row) { + // row >= height => use last input row + int clamped_row = (row < height) ? row : height - 1; + int base_p = (stbi__flip_vertically_on_write ? (height-1-clamped_row) : clamped_row)*width*comp; + for(col = x; col < x+16; ++col, ++pos) { + // if col >= width => use pixel from last input column + int p = base_p + ((col < width) ? col : (width-1))*comp; + float r = dataR[p], g = dataG[p], b = dataB[p]; + Y[pos]= +0.29900f*r + 0.58700f*g + 0.11400f*b - 128; + U[pos]= -0.16874f*r - 0.33126f*g + 0.50000f*b; + V[pos]= +0.50000f*r - 0.41869f*g - 0.08131f*b; + } + } + DCY = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, Y+0, 16, fdtbl_Y, DCY, YDC_HT, YAC_HT); + DCY = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, Y+8, 16, fdtbl_Y, DCY, YDC_HT, YAC_HT); + DCY = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, Y+128, 16, fdtbl_Y, DCY, YDC_HT, YAC_HT); + DCY = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, Y+136, 16, fdtbl_Y, DCY, YDC_HT, YAC_HT); + + // subsample U,V + { + float subU[64], subV[64]; + int yy, xx; + for(yy = 0, pos = 0; yy < 8; ++yy) { + for(xx = 0; xx < 8; ++xx, ++pos) { + int j = yy*32+xx*2; + subU[pos] = (U[j+0] + U[j+1] + U[j+16] + U[j+17]) * 0.25f; + subV[pos] = (V[j+0] + V[j+1] + V[j+16] + V[j+17]) * 0.25f; + } + } + DCU = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, subU, 8, fdtbl_UV, DCU, UVDC_HT, UVAC_HT); + DCV = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, subV, 8, fdtbl_UV, DCV, UVDC_HT, UVAC_HT); + } + } + } + } else { + for(y = 0; y < height; y += 8) { + for(x = 0; x < width; x += 8) { + float Y[64], U[64], V[64]; + for(row = y, pos = 0; row < y+8; ++row) { + // row >= height => use last input row + int clamped_row = (row < height) ? row : height - 1; + int base_p = (stbi__flip_vertically_on_write ? (height-1-clamped_row) : clamped_row)*width*comp; + for(col = x; col < x+8; ++col, ++pos) { + // if col >= width => use pixel from last input column + int p = base_p + ((col < width) ? col : (width-1))*comp; + float r = dataR[p], g = dataG[p], b = dataB[p]; + Y[pos]= +0.29900f*r + 0.58700f*g + 0.11400f*b - 128; + U[pos]= -0.16874f*r - 0.33126f*g + 0.50000f*b; + V[pos]= +0.50000f*r - 0.41869f*g - 0.08131f*b; + } + } + + DCY = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, Y, 8, fdtbl_Y, DCY, YDC_HT, YAC_HT); + DCU = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, U, 8, fdtbl_UV, DCU, UVDC_HT, UVAC_HT); + DCV = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, V, 8, fdtbl_UV, DCV, UVDC_HT, UVAC_HT); + } + } + } + + // Do the bit alignment of the EOI marker + stbiw__jpg_writeBits(s, &bitBuf, &bitCnt, fillBits); + } + + // EOI + stbiw__putc(s, 0xFF); + stbiw__putc(s, 0xD9); + + return 1; +} + +STBIWDEF int stbi_write_jpg_to_func(stbi_write_func *func, void *context, int x, int y, int comp, const void *data, int quality) +{ + stbi__write_context s = { 0 }; + stbi__start_write_callbacks(&s, func, context); + return stbi_write_jpg_core(&s, x, y, comp, (void *) data, quality); +} + + +#ifndef STBI_WRITE_NO_STDIO +STBIWDEF int stbi_write_jpg(char const *filename, int x, int y, int comp, const void *data, int quality) +{ + stbi__write_context s = { 0 }; + if (stbi__start_write_file(&s,filename)) { + int r = stbi_write_jpg_core(&s, x, y, comp, data, quality); + stbi__end_write_file(&s); + return r; + } else + return 0; +} +#endif + +#endif // STB_IMAGE_WRITE_IMPLEMENTATION + +/* Revision history + 1.16 (2021-07-11) + make Deflate code emit uncompressed blocks when it would otherwise expand + support writing BMPs with alpha channel + 1.15 (2020-07-13) unknown + 1.14 (2020-02-02) updated JPEG writer to downsample chroma channels + 1.13 + 1.12 + 1.11 (2019-08-11) + + 1.10 (2019-02-07) + support utf8 filenames in Windows; fix warnings and platform ifdefs + 1.09 (2018-02-11) + fix typo in zlib quality API, improve STB_I_W_STATIC in C++ + 1.08 (2018-01-29) + add stbi__flip_vertically_on_write, external zlib, zlib quality, choose PNG filter + 1.07 (2017-07-24) + doc fix + 1.06 (2017-07-23) + writing JPEG (using Jon Olick's code) + 1.05 ??? + 1.04 (2017-03-03) + monochrome BMP expansion + 1.03 ??? + 1.02 (2016-04-02) + avoid allocating large structures on the stack + 1.01 (2016-01-16) + STBIW_REALLOC_SIZED: support allocators with no realloc support + avoid race-condition in crc initialization + minor compile issues + 1.00 (2015-09-14) + installable file IO function + 0.99 (2015-09-13) + warning fixes; TGA rle support + 0.98 (2015-04-08) + added STBIW_MALLOC, STBIW_ASSERT etc + 0.97 (2015-01-18) + fixed HDR asserts, rewrote HDR rle logic + 0.96 (2015-01-17) + add HDR output + fix monochrome BMP + 0.95 (2014-08-17) + add monochrome TGA output + 0.94 (2014-05-31) + rename private functions to avoid conflicts with stb_image.h + 0.93 (2014-05-27) + warning fixes + 0.92 (2010-08-01) + casts to unsigned char to fix warnings + 0.91 (2010-07-17) + first public release + 0.90 first internal release +*/ + +/* +------------------------------------------------------------------------------ +This software is available under 2 licenses -- choose whichever you prefer. +------------------------------------------------------------------------------ +ALTERNATIVE A - MIT License +Copyright (c) 2017 Sean Barrett +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +------------------------------------------------------------------------------ +ALTERNATIVE B - Public Domain (www.unlicense.org) +This is free and unencumbered software released into the public domain. +Anyone is free to copy, modify, publish, use, compile, sell, or distribute this +software, either in source code form or as a compiled binary, for any purpose, +commercial or non-commercial, and by any means. +In jurisdictions that recognize copyright laws, the author or authors of this +software dedicate any and all copyright interest in the software to the public +domain. We make this dedication for the benefit of the public at large and to +the detriment of our heirs and successors. We intend this dedication to be an +overt act of relinquishment in perpetuity of all present and future rights to +this software under copyright law. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------------------------------------------------------------ +*/ diff --git a/src/viewmodel/sticky_view_model.cpp b/src/viewmodel/sticky_view_model.cpp new file mode 100644 index 0000000..f65ff59 --- /dev/null +++ b/src/viewmodel/sticky_view_model.cpp @@ -0,0 +1,110 @@ +#include "sticky_view_model.h" +#include +#include + +StickyViewModel::StickyViewModel(StickyImageStore &store, QObject *parent) + : QObject(parent) + , m_store(store) +{ +} + +void StickyViewModel::requestSticky(const QString &imageUrl, const QRect &imgRect) +{ + if (imageUrl.isEmpty()) return; + emit stickyReady(imageUrl, imgRect); +} + +void StickyViewModel::releaseImage(const QString &imageUrl) +{ + m_store.releaseImage(imageUrl); +} + +bool StickyViewModel::saveImage(const QString &imageUrl, const QUrl &targetUrl) +{ + return m_store.saveImage(imageUrl, targetUrl); +} + +bool StickyViewModel::copyImageToClipboard(const QString &imageUrl) +{ + return m_store.copyImageToClipboard(imageUrl); +} + +QImage StickyViewModel::getImageByUrl(const QString &imageUrl) const +{ + return m_store.getImageByUrl(imageUrl); +} + +QImage StickyViewModel::mergeLayers(const QString &imageUrl, const QImage &annotationLayer) const +{ + QImage base = m_store.getImageByUrl(imageUrl); + if (base.isNull()) { + return {}; + } + if (annotationLayer.isNull()) { + return base; + } + + QImage merged = base.convertToFormat(QImage::Format_ARGB32); + QPainter painter(&merged); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.drawImage(0, 0, annotationLayer); + painter.end(); + return merged; +} + +bool StickyViewModel::overwriteWithAnnotations(const QString &imageUrl, const QImage &annotationLayer) +{ + const QImage merged = mergeLayers(imageUrl, annotationLayer); + if (merged.isNull()) { + return false; + } + return m_store.replaceImage(imageUrl, merged); +} + +bool StickyViewModel::saveMergedImage(const QString &imageUrl, const QImage &annotationLayer, const QUrl &targetUrl) +{ + if (targetUrl.isEmpty() || !targetUrl.isLocalFile()) { + return false; + } + const QImage merged = mergeLayers(imageUrl, annotationLayer); + if (merged.isNull()) { + return false; + } + return merged.save(targetUrl.toLocalFile()); +} + +bool StickyViewModel::rotateImage(const QString &imageUrl, int degreesClockwise) +{ + QImage img = m_store.getImageByUrl(imageUrl); + if (img.isNull()) { + return false; + } + + int normalized = degreesClockwise % 360; + if (normalized < 0) { + normalized += 360; + } + if (normalized % 90 != 0) { + return false; + } + + if (normalized != 0) { + QTransform t; + t.rotate(normalized); + img = img.transformed(t, Qt::SmoothTransformation); + } + return m_store.replaceImage(imageUrl, img); +} + +bool StickyViewModel::mirrorImage(const QString &imageUrl, bool horizontal, bool vertical) +{ + QImage img = m_store.getImageByUrl(imageUrl); + if (img.isNull()) { + return false; + } + if (!horizontal && !vertical) { + return true; + } + img = img.mirrored(horizontal, vertical); + return m_store.replaceImage(imageUrl, img); +} diff --git a/src/viewmodel/sticky_view_model.h b/src/viewmodel/sticky_view_model.h new file mode 100644 index 0000000..eaaa904 --- /dev/null +++ b/src/viewmodel/sticky_view_model.h @@ -0,0 +1,47 @@ +#ifndef STICKY_VIEW_MODEL_H +#define STICKY_VIEW_MODEL_H + +#include +#include +#include +#include "sticky_image_store.h" + +// StickyViewModel +// --------------- +// 负责贴图会话的业务逻辑: +// - 从 StickyImageStore 读写贴图数据(save/copy/release) +// - 通过信号通知 QML 创建贴图窗口(View 监听信号后自行创建 TZStickyWindow) +// QML 侧只需监听 stickyReady 信号,无需知道截图细节。 + +class StickyViewModel : public QObject +{ + Q_OBJECT + +public: + explicit StickyViewModel(StickyImageStore &store, QObject *parent = nullptr); + + // 通知 QML 创建贴图窗口,由 ScreenshotViewModel::captureRectToStickyUrl 产生的 URL 触发 + // QML 监听 stickyReady(imageUrl, imgRect) 信号后创建 TZStickyWindow + Q_INVOKABLE void requestSticky(const QString &imageUrl, const QRect &imgRect); + + // 以下三个方法供 TZStickyWindow.qml 直接调用(贴图窗口内部操作) + Q_INVOKABLE void releaseImage(const QString &imageUrl); + Q_INVOKABLE bool saveImage(const QString &imageUrl, const QUrl &targetUrl); + Q_INVOKABLE bool copyImageToClipboard(const QString &imageUrl); + Q_INVOKABLE QImage getImageByUrl(const QString &imageUrl) const; + Q_INVOKABLE bool overwriteWithAnnotations(const QString &imageUrl, const QImage &annotationLayer); + Q_INVOKABLE bool saveMergedImage(const QString &imageUrl, const QImage &annotationLayer, const QUrl &targetUrl); + Q_INVOKABLE bool rotateImage(const QString &imageUrl, int degreesClockwise); + Q_INVOKABLE bool mirrorImage(const QString &imageUrl, bool horizontal, bool vertical); + +signals: + // QML 监听此信号,收到后创建并显示 TZStickyWindow + void stickyReady(const QString &imageUrl, const QRect &imgRect); + +private: + QImage mergeLayers(const QString &imageUrl, const QImage &annotationLayer) const; + + StickyImageStore &m_store; +}; + +#endif // STICKY_VIEW_MODEL_H diff --git a/src/viewmodel/storage_view_model.cpp b/src/viewmodel/storage_view_model.cpp new file mode 100644 index 0000000..9494c6a --- /dev/null +++ b/src/viewmodel/storage_view_model.cpp @@ -0,0 +1,120 @@ +#include "storage_view_model.h" + +#include +#include +#include +#include + +StorageViewModel::StorageViewModel(AppSettings &settings, QObject *parent) + : QObject(parent) + , m_settings(settings) +{ + connect(&m_netManager, &QNetworkAccessManager::finished, + this, [this](QNetworkReply *reply) { + if (reply->error() == QNetworkReply::NoError) { + QByteArray data = reply->readAll(); + QImage img; + if (img.loadFromData(data)) { + img.save(m_pendingTargetUrl.toLocalFile()); + } else { + qWarning() << "[StorageViewModel] 网络图片解码失败"; + } + } else { + qWarning() << "[StorageViewModel] 网络图片下载失败:" << reply->errorString(); + } + reply->deleteLater(); + }); +} + +QUrl StorageViewModel::savePath() const +{ + return QUrl::fromLocalFile(m_settings.savePath()); +} + +void StorageViewModel::setSavePath(const QUrl &path) +{ + QString localPath = path.toLocalFile(); + if (localPath.isEmpty()) localPath = path.toString(); + if (localPath.startsWith("file://")) + localPath = QUrl(localPath).toLocalFile(); + + if (localPath.isEmpty() || m_settings.savePath() == localPath) return; + + m_settings.setSavePath(localPath); + emit savePathChanged(savePath()); +} + +bool StorageViewModel::saveImage(const QString &sourceUrl, const QUrl &targetUrl) +{ + if (sourceUrl.isEmpty()) { + qWarning() << "[StorageViewModel] 源地址为空"; + return false; + } + if (targetUrl.isEmpty() || !targetUrl.isLocalFile()) { + qWarning() << "[StorageViewModel] 目标路径无效"; + return false; + } + + const QString trimmed = sourceUrl.trimmed(); + + // Base64 + if (trimmed.startsWith("data:image/")) { + int idx = trimmed.indexOf(";base64,"); + if (idx > 0 && trimmed.length() - idx > 8) + return saveBase64Image(trimmed.mid(idx + 8), targetUrl); + } + + // 网络图片(异步) + const QUrl srcUrl(sourceUrl); + if (srcUrl.isValid() && + (srcUrl.scheme() == "http" || srcUrl.scheme() == "https")) { + saveNetworkImage(sourceUrl, targetUrl); + return false; // 异步,结果通过信号通知 + } + + // 本地 / QRC + return saveLocalImage(QUrl::fromUserInput(sourceUrl), targetUrl); +} + +bool StorageViewModel::saveBase64Image(const QString &base64Str, const QUrl &targetUrl) +{ + QByteArray data = QByteArray::fromBase64(base64Str.toUtf8()); + if (data.isEmpty()) { + qWarning() << "[StorageViewModel] Base64 解码失败"; + return false; + } + QImage img; + if (!img.loadFromData(data)) { + qWarning() << "[StorageViewModel] Base64 数据转图片失败"; + return false; + } + return img.save(targetUrl.toLocalFile()); +} + +bool StorageViewModel::saveLocalImage(const QUrl &sourceUrl, const QUrl &targetUrl) +{ + QImage img; + if (sourceUrl.isLocalFile()) + img.load(sourceUrl.toLocalFile()); + else if (sourceUrl.scheme() == "qrc") + img.load(sourceUrl.toString().replace("qrc:/", ":/")); + else { + qWarning() << "[StorageViewModel] 不支持的图片来源:" << sourceUrl; + return false; + } + if (img.isNull()) { + qWarning() << "[StorageViewModel] 图片加载失败:" << sourceUrl; + return false; + } + return img.save(targetUrl.toLocalFile()); +} + +void StorageViewModel::saveNetworkImage(const QString &networkUrl, const QUrl &targetUrl) +{ + m_pendingTargetUrl = targetUrl; + QNetworkRequest req; + req.setUrl(QUrl(networkUrl)); + req.setRawHeader("User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + m_netManager.get(req); +} diff --git a/src/viewmodel/storage_view_model.h b/src/viewmodel/storage_view_model.h new file mode 100644 index 0000000..dbdc2d5 --- /dev/null +++ b/src/viewmodel/storage_view_model.h @@ -0,0 +1,42 @@ +#ifndef STORAGE_VIEW_MODEL_H +#define STORAGE_VIEW_MODEL_H + +#include +#include +#include +#include "model/app_settings.h" + +// StorageViewModel +// ---------------- +// 负责截图文件保存的业务编排,以及 savePath 的 Q_PROPERTY 暴露。 +// 数据(savePath)由 AppSettings Model 持久化, +// 文件 I/O 逻辑(Base64/URL/网络图片)保留在本类。 + +class StorageViewModel : public QObject +{ + Q_OBJECT + Q_PROPERTY(QUrl savePath READ savePath NOTIFY savePathChanged) + +public: + explicit StorageViewModel(AppSettings &settings, QObject *parent = nullptr); + + QUrl savePath() const; + Q_INVOKABLE void setSavePath(const QUrl &path); + + // 统一保存入口:自动识别 Base64 / 网络 URL / 本地路径 + Q_INVOKABLE bool saveImage(const QString &sourceUrl, const QUrl &targetUrl); + +signals: + void savePathChanged(const QUrl &savePath); + +private: + bool saveBase64Image(const QString &base64Str, const QUrl &targetUrl); + bool saveLocalImage(const QUrl &sourceUrl, const QUrl &targetUrl); + void saveNetworkImage(const QString &networkUrl, const QUrl &targetUrl); + + AppSettings &m_settings; + QNetworkAccessManager m_netManager; + QUrl m_pendingTargetUrl; +}; + +#endif // STORAGE_VIEW_MODEL_H