From 20bc24a028ef2e278c21d0dfe04a351dcffb4c72 Mon Sep 17 00:00:00 2001 From: impressionyang Date: Mon, 15 Jun 2026 16:43:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(linux):=20=E6=96=B0=E5=A2=9E=E5=A4=9A?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=BF=AB=E6=8D=B7=E9=94=AE=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=92=8C=E5=BD=95=E9=9F=B3=E6=8C=87=E7=A4=BA=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - XCB 后端:X11 下 XGrabKey 零权限全局快捷键 - evdev 后端:Wayland 下 EVIOCGRAB + uinput 键盘事件拦截与重放 - GNOME 扩展 D-Bus 后端:GNOME Wayland 备用方案 - Portal GlobalShortcuts 后端:标准 Portal 快捷键 - libei 后端:GNOME 47+/KDE 实验性支持 - 后端工厂:根据会话类型和权限自动选择最优后端 - 录音指示器:浮动窗口在光标位置显示录音状态 Co-Authored-By: Claude Opus 4.6 --- .../extension.js | 125 +++++ .../metadata.json | 8 + package-linux.sh | 218 +++++++++ src/core/caps_lock_backend.cpp | 114 +++++ src/core/caps_lock_backend.h | 73 +++ src/core/caps_lock_evdev.cpp | 426 ++++++++++++++++++ src/core/caps_lock_evdev.h | 45 ++ src/core/caps_lock_xcb.cpp | 203 +++++++++ src/core/caps_lock_xcb.h | 46 ++ src/ui/widgets/recording_indicator.cpp | 131 ++++++ src/ui/widgets/recording_indicator.h | 41 ++ 11 files changed, 1430 insertions(+) create mode 100644 gnome-extension/io.impress.voice-input-hotkey@impress/extension.js create mode 100644 gnome-extension/io.impress.voice-input-hotkey@impress/metadata.json create mode 100755 package-linux.sh create mode 100644 src/core/caps_lock_backend.cpp create mode 100644 src/core/caps_lock_backend.h create mode 100644 src/core/caps_lock_evdev.cpp create mode 100644 src/core/caps_lock_evdev.h create mode 100644 src/core/caps_lock_xcb.cpp create mode 100644 src/core/caps_lock_xcb.h create mode 100644 src/ui/widgets/recording_indicator.cpp create mode 100644 src/ui/widgets/recording_indicator.h diff --git a/gnome-extension/io.impress.voice-input-hotkey@impress/extension.js b/gnome-extension/io.impress.voice-input-hotkey@impress/extension.js new file mode 100644 index 0000000..b95b73d --- /dev/null +++ b/gnome-extension/io.impress.voice-input-hotkey@impress/extension.js @@ -0,0 +1,125 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import Clutter from 'gi://Clutter'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; + +const DBUS_IFACE = ` + + + + + +`; + +const DBUS_PATH = '/io/impress/VoiceInputHotkey'; +const CAPS_LOCK_KEYSYM = 0xffe5; + +export default class VoiceInputHotkeyExtension extends Extension { + constructor(metadata) { + super(metadata); + this._dbusNode = null; + this._captorId = 0; + this._releaseCaptorId = 0; + this._retryId = 0; + } + + enable() { + try { + this._dbusNode = Gio.DBusExportedObject.wrapJSObject(DBUS_IFACE, {}); + this._dbusNode.export(Gio.DBus.session, DBUS_PATH); + this.log('D-Bus 已导出'); + + this._tryConnect(); + } catch (e) { + this.log('enable 失败: ' + e.message); + } + } + + _tryConnect() { + if (this._retryId) { + GLib.Source.remove(this._retryId); + this._retryId = 0; + } + + // 优先使用 uiGroup(始终可用),回退到 global.stage + let target = null; + try { + target = Main.layoutManager?.uiGroup || null; + } catch (e) {} + if (!target) { + try { target = global.stage || null; } catch (e) {} + } + + if (!target) { + this.log('uiGroup 和 stage 均不可用,200ms 后重试'); + this._retryId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 200, () => { + this._retryId = 0; + this._tryConnect(); + return GLib.SOURCE_REMOVE; + }); + return; + } + + try { + this._captorId = target.connect('captured-event::key-press', (_actor, event) => { + if (event.type() !== Clutter.EventType.KEY_PRESS) return Clutter.EVENT_PROPAGATE; + const sym = event.get_key_symbol(); + if (sym === CAPS_LOCK_KEYSYM || sym === Clutter.KEY_Caps_Lock) { + this._dbusNode.emit_signal('CapsLockPressed', null); + this.log('CapsLock pressed'); + } + return Clutter.EVENT_PROPAGATE; + }); + + this._releaseCaptorId = target.connect('captured-event::key-release', (_actor, event) => { + if (event.type() !== Clutter.EventType.KEY_RELEASE) return Clutter.EVENT_PROPAGATE; + const sym = event.get_key_symbol(); + if (sym === CAPS_LOCK_KEYSYM || sym === Clutter.KEY_Caps_Lock) { + this._dbusNode.emit_signal('CapsLockReleased', null); + this.log('CapsLock released'); + } + return Clutter.EVENT_PROPAGATE; + }); + + this.log('全局按键捕获已就绪'); + } catch (e) { + this.log('连接事件失败: ' + e.message); + this._retryId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => { + this._retryId = 0; + this._tryConnect(); + return GLib.SOURCE_REMOVE; + }); + } + } + + disable() { + try { + if (this._retryId) { + GLib.Source.remove(this._retryId); + this._retryId = 0; + } + + // 从所有可能的目标断开 + let targets = []; + try { if (Main.layoutManager?.uiGroup) targets.push(Main.layoutManager.uiGroup); } catch (e) {} + try { if (global.stage) targets.push(global.stage); } catch (e) {} + + for (const t of targets) { + if (this._captorId) { try { t.disconnect(this._captorId); } catch (e) {} } + if (this._releaseCaptorId) { try { t.disconnect(this._releaseCaptorId); } catch (e) {} } + } + this._captorId = 0; + this._releaseCaptorId = 0; + + if (this._dbusNode) { this._dbusNode.unexport(); this._dbusNode = null; } + this.log('Extension disabled'); + } catch (e) { + this.log('disable 异常: ' + e.message); + } + } + + log(msg) { + console.log(`[ImpressHotkey] ${msg}`); + } +} diff --git a/gnome-extension/io.impress.voice-input-hotkey@impress/metadata.json b/gnome-extension/io.impress.voice-input-hotkey@impress/metadata.json new file mode 100644 index 0000000..3b2a7d8 --- /dev/null +++ b/gnome-extension/io.impress.voice-input-hotkey@impress/metadata.json @@ -0,0 +1,8 @@ +{ + "uuid": "io.impress.voice-input-hotkey@impress", + "name": "Impress Voice Input Hotkey", + "description": "Provides CapsLock global key events for Impress Voice Input via D-Bus", + "version": 1, + "shell-version": ["46", "47", "48", "50"], + "url": "https://gitea.impressionyang.top/impressionyang/impress_voice_input" +} diff --git a/package-linux.sh b/package-linux.sh new file mode 100755 index 0000000..63d37f3 --- /dev/null +++ b/package-linux.sh @@ -0,0 +1,218 @@ +#!/bin/bash +# ============================================================================ +# Impress Voice Input — Linux 发布包打包脚本 +# 用法: ./package-linux.sh [--clean] +# ============================================================================ +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BUILD_DIR="${SCRIPT_DIR}/build_linux" +DIST_DIR="${SCRIPT_DIR}/dist" +PKG_NAME="impress_voice_input_linux" +PKG_DIR="${DIST_DIR}/${PKG_NAME}" + +for arg in "$@"; do + case "$arg" in + --clean) + echo "清理发布包目录..." + rm -rf "${PKG_DIR}" "${DIST_DIR}/${PKG_NAME}.tar.gz" + ;; + esac +done + +# 1. 确保已编译 +if [ ! -f "${BUILD_DIR}/impress_voice_input" ]; then + echo "错误:未找到可执行文件,请先运行 ./build-linux.sh" + exit 1 +fi + +# 2. 创建发布目录结构 +echo "[1/6] 创建发布目录..." +rm -rf "${PKG_DIR}" +mkdir -p "${PKG_DIR}/lib" +mkdir -p "${PKG_DIR}/platforms" +mkdir -p "${PKG_DIR}/styles" +mkdir -p "${PKG_DIR}/gnome-extension/io.impress.voice-input-hotkey@impress" + +# 3. 复制核心文件 +echo "[2/6] 复制可执行文件和资源..." +cp "${BUILD_DIR}/impress_voice_input" "${PKG_DIR}/" +cp "${SCRIPT_DIR}/configs/default_config.json" "${PKG_DIR}/default_config.json" +cp "${SCRIPT_DIR}/src/ui/resources/styles/main.qss" "${PKG_DIR}/styles/" 2>/dev/null || true +cp "${SCRIPT_DIR}/src/ui/resources/styles/main_dark.qss" "${PKG_DIR}/styles/" 2>/dev/null || true + +# 4. 复制依赖库 +echo "[3/6] 复制依赖库..." +cp "${SCRIPT_DIR}/third_party/onnxruntime/lib/libonnxruntime.so"* "${PKG_DIR}/lib/" +cp "${SCRIPT_DIR}/third_party/portaudio/lib/libportaudio.so"* "${PKG_DIR}/lib/" 2>/dev/null || true + +for lib in libQt6Core.so.6 libQt6Widgets.so.6 libQt6Gui.so.6 libQt6Concurrent.so.6 libQt6Network.so.6; do + src=$(find /home/alvin/Qt/6.11.1/gcc_64/lib -name "${lib}" -o -name "${lib}.*" 2>/dev/null | head -1) + [ -n "$src" ] && cp -L "$src" "${PKG_DIR}/lib/" || true +done + +for lib in libicui18n.so.77 libicuuc.so.77 libicudata.so.77; do + src=$(find /home/alvin/Qt/6.11.1/gcc_64/lib -name "${lib}" -o -name "${lib}.*" 2>/dev/null | head -1) + [ -n "$src" ] && cp -L "$src" "${PKG_DIR}/lib/" || true +done + +for lib in libpcre2-16.so.0 libfontconfig.so.1 libfreetype.so.6; do + src=$(find /usr/lib64 /usr/lib -name "${lib}" -o -name "${lib}.*" 2>/dev/null | head -1) + [ -n "$src" ] && cp -L "$src" "${PKG_DIR}/lib/" || true +done + +for lib in libei.so.1 libeis.so.1; do + src=$(find /usr/lib64 /usr/lib -name "${lib}" -o -name "${lib}.*" 2>/dev/null | head -1) + [ -n "$src" ] && cp -L "$src" "${PKG_DIR}/lib/" || true +done + +QT_PLUGIN_DIR=$(find /home/alvin/Qt -path "*/plugins/platforms" -type d 2>/dev/null | head -1) +if [ -n "$QT_PLUGIN_DIR" ]; then + cp "${QT_PLUGIN_DIR}"/libq*.so "${PKG_DIR}/platforms/" 2>/dev/null || true +fi + +# 5. 复制 GNOME 扩展 +echo "[4/6] 复制 GNOME Shell 扩展..." +cp "${SCRIPT_DIR}/gnome-extension/io.impress.voice-input-hotkey@impress/extension.js" \ + "${PKG_DIR}/gnome-extension/io.impress.voice-input-hotkey@impress/" +cp "${SCRIPT_DIR}/gnome-extension/io.impress.voice-input-hotkey@impress/metadata.json" \ + "${PKG_DIR}/gnome-extension/io.impress.voice-input-hotkey@impress/" + +# 6. 生成脚本和桌面文件 +echo "[5/6] 生成安装脚本和启动脚本..." + +# --- install.sh --- +cat > "${PKG_DIR}/install.sh" << 'INSTALLSH' +#!/bin/bash +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_NAME="impress_voice_input" +GNOME_EXT_UUID="io.impress.voice-input-hotkey@impress" +INSTALL_MODE="user" +for arg in "$@"; do case "$arg" in --system) INSTALL_MODE="system" ;; --user) INSTALL_MODE="user" ;; esac; done + +echo "============================================" +echo " Impress Voice Input — 安装" +echo " 模式: ${INSTALL_MODE}" +echo "============================================" + +echo "" +echo "[1/3] GNOME Shell 扩展(GNOME 用户可选)..." +if [ -d "${SCRIPT_DIR}/gnome-extension/${GNOME_EXT_UUID}" ]; then + if [ "$INSTALL_MODE" = "system" ]; then + EXT_DST="/usr/share/gnome-shell/extensions/${GNOME_EXT_UUID}" + echo " 系统安装: ${EXT_DST}" + sudo mkdir -p "$(dirname "${EXT_DST}")" + sudo rm -rf "${EXT_DST}" + sudo cp -r "${SCRIPT_DIR}/gnome-extension/${GNOME_EXT_UUID}" "${EXT_DST}" + sudo chmod -R o+r "${EXT_DST}" + else + EXT_DST="${HOME}/.local/share/gnome-shell/extensions/${GNOME_EXT_UUID}" + echo " 用户安装: ${EXT_DST}" + mkdir -p "$(dirname "${EXT_DST}")" + rm -rf "${EXT_DST}" + cp -r "${SCRIPT_DIR}/gnome-extension/${GNOME_EXT_UUID}" "${EXT_DST}" + fi + echo " ✓ GNOME 扩展已安装" + + ENABLED=$(gsettings get org.gnome.shell enabled-extensions 2>/dev/null || echo "[]") + if echo "${ENABLED}" | grep -q "${GNOME_EXT_UUID}"; then + echo " ✓ 扩展已在启用列表中" + else + echo " 添加到 enabled-extensions..." + NEW_LIST=$(echo "${ENABLED}" | sed 's/\]$/,"'"${GNOME_EXT_UUID}"'"]/') + gsettings set org.gnome.shell enabled-extensions "${NEW_LIST}" 2>/dev/null || { + echo " ⚠ 无法自动启用,请在「扩展」应用中手动启用" + } + fi +else + echo " ⚠ 未找到扩展目录,跳过" +fi + +echo "" +echo "[2/3] Wayland evdev 权限(Wayland 用户可选)..." +SESSION_TYPE="${XDG_SESSION_TYPE:-}" +if [ "$SESSION_TYPE" = "wayland" ] && [ ! -w /dev/uinput ]; then + echo " 当前为 Wayland 会话,evdev 快捷键需要 input 组权限" + echo " 执行以下命令并重新登录:" + echo " sudo usermod -aG input $USER" + echo " 或创建 udev 规则:" + echo ' KERNEL=="uinput", MODE="0660", GROUP="input"' + echo ' KERNEL=="event*", SUBSYSTEM=="input", MODE="0660", GROUP="input"' +else + echo " ✓ evdev 权限正常(X11 或已有 input 组访问)" +fi + +echo "" +echo "[3/3] Desktop Entry..." +if [ -f "${SCRIPT_DIR}/${APP_NAME}.desktop" ]; then + if [ "$INSTALL_MODE" = "system" ]; then + DST="/usr/local/share/applications/${APP_NAME}.desktop" + sudo mkdir -p "$(dirname "${DST}")" + sudo cp "${SCRIPT_DIR}/${APP_NAME}.desktop" "${DST}" + sudo sed -i "s|^Exec=.*|Exec=${SCRIPT_DIR}/run.sh|" "${DST}" + echo " ✓ 已安装到 ${DST}" + else + DST="${HOME}/.local/share/applications/${APP_NAME}.desktop" + mkdir -p "$(dirname "${DST}")" + cp "${SCRIPT_DIR}/${APP_NAME}.desktop" "${DST}" + sed -i "s|^Exec=.*|Exec=${SCRIPT_DIR}/run.sh|" "${DST}" + echo " ✓ 已安装到 ${DST}" + fi +else + echo " ⚠ 未找到 .desktop 文件" +fi + +echo "" +echo "============================================" +echo " 安装完成" +echo "============================================" +echo "" +echo "启动: ${SCRIPT_DIR}/run.sh 或从应用菜单搜索" +echo "" +echo "快捷键方案(自动检测,默认 Ctrl+Alt+C 可自定义):" +echo " 1. X11: XGrabKey(零权限)" +echo " 2. Wayland: evdev(需 input 组)" +echo " 3. GNOME 扩展: D-Bus 信号(备用)" +if echo "${ENABLED:-}" | grep -q "${GNOME_EXT_UUID}" 2>/dev/null; then + echo " 4. GNOME 扩展已启用" +fi +INSTALLSH +chmod +x "${PKG_DIR}/install.sh" + +# --- run.sh --- +cat > "${PKG_DIR}/run.sh" << 'RUNSH' +#!/bin/bash +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +export LD_LIBRARY_PATH="${SCRIPT_DIR}/lib:${LD_LIBRARY_PATH}" +export QT_QPA_PLATFORM_PLUGIN_PATH="${SCRIPT_DIR}/platforms" +export XDG_DESKTOP_PORTAL_APP_ID="io.impress.voice-input" + +if [ -d "${SCRIPT_DIR}/gnome-extension" ] && \ + [ ! -d "${HOME}/.local/share/gnome-shell/extensions/io.impress.voice-input-hotkey@impress" ]; then + echo "GNOME 用户推荐安装 CapsLock 快捷键扩展(一次即可):" + echo " ./install.sh" + echo "" +fi + +exec "${SCRIPT_DIR}/impress_voice_input" "$@" +RUNSH +chmod +x "${PKG_DIR}/run.sh" + +# desktop entry +cp "${DIST_DIR}/${PKG_NAME}/io.impress.voice-input.desktop" "${PKG_DIR}/" 2>/dev/null || true + +# 7. 打包 +echo "[6/6] 打包..." +cd "${DIST_DIR}" +tar czf "${PKG_NAME}.tar.gz" "${PKG_NAME}/" + +echo "" +echo "发布包已生成: ${DIST_DIR}/${PKG_NAME}.tar.gz" +echo "大小: $(du -h "${DIST_DIR}/${PKG_NAME}.tar.gz" | cut -f1)" +echo "" +echo "使用方式:" +echo " tar xzf impress_voice_input_linux.tar.gz" +echo " cd impress_voice_input_linux" +echo " ./install.sh # 安装 GNOME 扩展 + 桌面入口" +echo " ./run.sh # 启动应用" diff --git a/src/core/caps_lock_backend.cpp b/src/core/caps_lock_backend.cpp new file mode 100644 index 0000000..778bc78 --- /dev/null +++ b/src/core/caps_lock_backend.cpp @@ -0,0 +1,114 @@ +#include "caps_lock_backend.h" +#include "utils/logger.h" + +#include +#include +#include + +#ifdef Q_OS_LINUX +#include +#include "caps_lock_xcb.h" +#include "caps_lock_evdev.h" +#endif + +static const char* const kTag = "HotkeyBackend"; + +namespace impress { + +bool parseHotkeyCombo(const QString& combo, int& modifiers, int& key) { + if (combo.isEmpty() || combo == "未设置" || combo == "CapsLock") { + return false; + } + + modifiers = 0; + key = 0; + + QStringList parts = combo.split('+', Qt::SkipEmptyParts); + if (parts.isEmpty()) return false; + + for (int i = 0; i < parts.size() - 1; i++) { + QString part = parts[i].trimmed().toLower(); + if (part == "ctrl" || part == "control") modifiers |= Qt::ControlModifier; + else if (part == "alt") modifiers |= Qt::AltModifier; + else if (part == "shift") modifiers |= Qt::ShiftModifier; + else if (part == "meta" || part == "super" || part == "win") modifiers |= Qt::MetaModifier; + else { + LOG_WARNING(kTag, QString("未知的修饰键: %1").arg(part)); + return false; + } + } + + // 最后一个部分是主键 + QString mainKey = parts.last().trimmed(); + if (mainKey.length() == 1) { + // 单字母/数字 + QChar ch = mainKey[0]; + if (ch.isLetter()) { + key = Qt::Key_A + (ch.toUpper().unicode() - 'A'); + } else if (ch.isDigit()) { + key = Qt::Key_0 + (ch.unicode() - '0'); + } + } else if (mainKey.startsWith('F') && mainKey.size() <= 3) { + bool ok; + int num = mainKey.mid(1).toInt(&ok); + if (ok && num >= 1 && num <= 35) { + key = Qt::Key_F1 + (num - 1); + } + } else if (mainKey.compare("space", Qt::CaseInsensitive) == 0) { + key = Qt::Key_Space; + } else if (mainKey.compare("enter", Qt::CaseInsensitive) == 0) { + key = Qt::Key_Return; + } else if (mainKey.compare("return", Qt::CaseInsensitive) == 0) { + key = Qt::Key_Return; + } else if (mainKey.compare("escape", Qt::CaseInsensitive) == 0) { + key = Qt::Key_Escape; + } else if (mainKey.compare("tab", Qt::CaseInsensitive) == 0) { + key = Qt::Key_Tab; + } else if (mainKey.compare("backspace", Qt::CaseInsensitive) == 0) { + key = Qt::Key_Backspace; + } else if (mainKey.compare("delete", Qt::CaseInsensitive) == 0) { + key = Qt::Key_Delete; + } else { + LOG_WARNING(kTag, QString("未知的主键: %1").arg(mainKey)); + return false; + } + + return key != 0; +} + +/** + * @brief 检测当前是否在 X11 下运行 + */ +static bool isX11Session() { + const char* sessionType = getenv("XDG_SESSION_TYPE"); + if (sessionType && std::strcmp(sessionType, "x11") == 0) + return true; + + // 回退:检查 DISPLAY 环境变量 + return getenv("DISPLAY") != nullptr; +} + +CapsLockBackend* createCapsLockBackend(QObject* parent) { + // 1. X11 会话优先使用 XGrabKey(零权限) + if (isX11Session()) { +#ifdef Q_OS_LINUX + return new XcbCapsLockBackend(parent); +#else + Q_UNUSED(parent); + return nullptr; +#endif + } + + // 2. Wayland 会话尝试 evdev(需权限) +#ifdef Q_OS_LINUX + // 先快速检查权限 + if (access("/dev/uinput", W_OK) == 0) { + return new EvdevCapsLockBackend(parent); + } +#endif + + // 3. 没有可用的纯 C++ 后端 + return nullptr; +} + +} // namespace impress diff --git a/src/core/caps_lock_backend.h b/src/core/caps_lock_backend.h new file mode 100644 index 0000000..3fbd35e --- /dev/null +++ b/src/core/caps_lock_backend.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include + +namespace impress { + +/** + * @brief 解析快捷键组合字符串 + * + * 支持格式: "Ctrl+Alt+C", "Ctrl+Shift+F1", "Alt+Space" 等 + * + * @param combo 如 "Ctrl+Alt+C" + * @param modifiers 输出 Qt::KeyboardModifiers + * @param key 输出 Qt::Key(主键) + * @return 解析是否成功 + */ +bool parseHotkeyCombo(const QString& combo, int& modifiers, int& key); + +/** + * @brief 全局快捷键抽象接口 + * + * 不同后端实现: + * - X11: XCB XGrabKey(零权限) + * - Wayland: evdev grab + uinput replay(需 input 组 + /dev/uinput) + */ +class CapsLockBackend : public QObject { + Q_OBJECT +public: + explicit CapsLockBackend(QObject* parent = nullptr) : QObject(parent) {} + virtual ~CapsLockBackend() = default; + + /** @brief 注册全局快捷键 */ + virtual bool start() = 0; + + /** @brief 注销快捷键 */ + virtual void stop() = 0; + + /** @brief 是否正在工作 */ + virtual bool isActive() const = 0; + + /** @brief 后端名称(用于日志) */ + virtual const char* backendName() const = 0; + + /** @brief 设置快捷键组合(在 start() 前调用) */ + virtual void setHotkeyCombo(int modifiers, int key) { + m_modifiers = modifiers; + m_key = key; + } + + /** @brief 获取当前设置的修饰符 */ + int modifiers() const { return m_modifiers; } + + /** @brief 获取当前设置的主键 */ + int key() const { return m_key; } + +signals: + void pressed(); + void released(); + void ready(); + void error(const QString& message); + +protected: + int m_modifiers = 0; + int m_key = 0; +}; + +/** + * @brief 工厂:根据当前会话类型自动选择后端 + */ +CapsLockBackend* createCapsLockBackend(QObject* parent = nullptr); + +} // namespace impress diff --git a/src/core/caps_lock_evdev.cpp b/src/core/caps_lock_evdev.cpp new file mode 100644 index 0000000..d675c58 --- /dev/null +++ b/src/core/caps_lock_evdev.cpp @@ -0,0 +1,426 @@ +#include "caps_lock_evdev.h" +#include "utils/logger.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +static const char* const kTag = "EvdevCapsLockBackend"; + +namespace impress { + +// Qt::Key → Linux evdev keycode 映射 +// 只映射常用键(字母、数字、功能键等) +static int qtKeyToEvdevCode(int key) { + if (key >= Qt::Key_A && key <= Qt::Key_Z) { + return KEY_A + (key - Qt::Key_A); + } + if (key >= Qt::Key_0 && key <= Qt::Key_9) { + return KEY_0 + (key - Qt::Key_0); + } + if (key >= Qt::Key_F1 && key <= Qt::Key_F12) { + return KEY_F1 + (key - Qt::Key_F1); + } + if (key >= Qt::Key_F13 && key <= Qt::Key_F24) { + return KEY_F13 + (key - Qt::Key_F13); + } + switch (key) { + case Qt::Key_Space: return KEY_SPACE; + case Qt::Key_Return: + case Qt::Key_Enter: return KEY_ENTER; + case Qt::Key_Escape: return KEY_ESC; + case Qt::Key_Tab: return KEY_TAB; + case Qt::Key_Backspace: return KEY_BACKSPACE; + case Qt::Key_Delete: return KEY_DELETE; + case Qt::Key_Insert: return KEY_INSERT; + case Qt::Key_Home: return KEY_HOME; + case Qt::Key_End: return KEY_END; + case Qt::Key_PageUp: return KEY_PAGEUP; + case Qt::Key_PageDown: return KEY_PAGEDOWN; + case Qt::Key_Up: return KEY_UP; + case Qt::Key_Down: return KEY_DOWN; + case Qt::Key_Left: return KEY_LEFT; + case Qt::Key_Right: return KEY_RIGHT; + case Qt::Key_Comma: return KEY_COMMA; + case Qt::Key_Period: return KEY_DOT; + case Qt::Key_Slash: return KEY_SLASH; + case Qt::Key_Semicolon: return KEY_SEMICOLON; + case Qt::Key_BracketLeft: return KEY_LEFTBRACE; + case Qt::Key_BracketRight: return KEY_RIGHTBRACE; + case Qt::Key_Apostrophe: return KEY_APOSTROPHE; + case Qt::Key_Minus: return KEY_MINUS; + case Qt::Key_Equal: return KEY_EQUAL; + case Qt::Key_Backslash: return KEY_BACKSLASH; + default: return 0; + } +} + +struct EvdevCapsLockBackend::Impl { + struct KeyboardDevice { + int fd = -1; + QString path; + bool grabbed = false; + }; + + int uinputFd = -1; + QList keyboards; + QTimer* retryTimer = nullptr; + int retryCount = 0; + static constexpr int MaxRetries = 3; + + // 当前修饰键状态(跟踪所有键盘的修饰键) + bool ctrlPressed = false; + bool altPressed = false; + bool shiftPressed = false; + bool metaPressed = false; + + // 快捷键配置 + int targetKeycode = 0; // evdev keycode + int targetModifiers = 0; // Qt::KeyboardModifiers + + /** 判断修饰键 */ + static bool isModifierKey(uint16_t code) { + return code == KEY_LEFTCTRL || code == KEY_RIGHTCTRL || + code == KEY_LEFTALT || code == KEY_RIGHTALT || + code == KEY_LEFTSHIFT || code == KEY_RIGHTSHIFT || + code == KEY_LEFTMETA || code == KEY_RIGHTMETA; + } + + /** 获取当前合成修饰符状态 */ + int currentModifiers() const { + int mods = 0; + if (ctrlPressed) mods |= Qt::ControlModifier; + if (altPressed) mods |= Qt::AltModifier; + if (shiftPressed) mods |= Qt::ShiftModifier; + if (metaPressed) mods |= Qt::MetaModifier; + return mods; + } + + /** 判断是否为快捷键组合 */ + bool isHotkeyCombo(uint16_t code) const { + if (code != (uint16_t)targetKeycode) return false; + return currentModifiers() == targetModifiers; + } + + /** 判断设备是否为键盘 */ + static bool isKeyboard(int fd) { + static constexpr size_t kEvdevBitArraySize = 64; + unsigned long evbits[kEvdevBitArraySize] = {}; + if (ioctl(fd, EVIOCGBIT(0, sizeof(evbits)), evbits) < 0) + return false; + + static constexpr size_t kEvKeyIdx = EV_KEY / (sizeof(unsigned long) * 8); + static constexpr int kEvKeyBit = EV_KEY % (sizeof(unsigned long) * 8); + if (!(evbits[kEvKeyIdx] & (1ULL << kEvKeyBit))) + return false; + + bool hasAlpha = false; + for (int code = KEY_Q; code <= KEY_P; code++) { + unsigned long keybits[KEY_CNT / (sizeof(unsigned long) * 8)] = {}; + if (ioctl(fd, EVIOCGBIT(EV_KEY, sizeof(keybits)), keybits) >= 0) { + if (keybits[code / (sizeof(unsigned long) * 8)] & (1ULL << (code % (sizeof(unsigned long) * 8)))) { + hasAlpha = true; + break; + } + } + } + return hasAlpha; + } + + /** 查找所有键盘设备 */ + QList findKeyboards() { + QList result; + QDir inputDir("/dev/input"); + QStringList filters = {"event*"}; + QFileInfoList files = inputDir.entryInfoList(filters, QDir::System | QDir::Readable); + + for (const QFileInfo& fi : files) { + QString path = fi.absoluteFilePath(); + int fd = open(qPrintable(path), O_RDONLY | O_NONBLOCK); + if (fd < 0) continue; + + if (isKeyboard(fd)) { + KeyboardDevice dev; + dev.fd = fd; + dev.path = path; + result.append(dev); + LOG_DEBUG(kTag, QString("发现键盘: %1").arg(path)); + } else { + close(fd); + } + } + return result; + } + + /** 创建 uinput 虚拟设备 */ + bool createUinputDevice() { + uinputFd = open("/dev/uinput", O_WRONLY | O_NONBLOCK); + if (uinputFd < 0) { + LOG_ERROR(kTag, "无法打开 /dev/uinput,需要 input 组或 udev 规则"); + return false; + } + + struct uinput_setup setup = {}; + memset(&setup, 0, sizeof(setup)); + strncpy(setup.name, "Impress Voice Input Virtual Keyboard", UINPUT_MAX_NAME_SIZE - 1); + setup.id.bustype = BUS_VIRTUAL; + setup.id.vendor = 0x1234; + setup.id.product = 0x5678; + setup.id.version = 1; + + ioctl(uinputFd, UI_SET_EVBIT, EV_KEY); + for (int code = 0; code < KEY_CNT; code++) { + ioctl(uinputFd, UI_SET_KEYBIT, code); + } + + if (ioctl(uinputFd, UI_DEV_SETUP, &setup) < 0) { + LOG_ERROR(kTag, "UI_DEV_SETUP 失败"); + close(uinputFd); + uinputFd = -1; + return false; + } + + if (ioctl(uinputFd, UI_DEV_CREATE) < 0) { + LOG_ERROR(kTag, "UI_DEV_CREATE 失败"); + close(uinputFd); + uinputFd = -1; + return false; + } + + LOG_INFO(kTag, "uinput 虚拟键盘已创建"); + return true; + } + + /** 抓取键盘设备 */ + bool grabAllKeyboards() { + for (auto& dev : keyboards) { + struct input_event ev; + while (read(dev.fd, &ev, sizeof(ev)) > 0) {} + + unsigned char keyState[KEY_MAX / 8 + 1]; + int attempts = 0; + while (attempts < 50) { + memset(keyState, 0, sizeof(keyState)); + if (ioctl(dev.fd, EVIOCGKEY(sizeof(keyState)), keyState) >= 0) { + bool allReleased = true; + for (size_t i = 0; i < sizeof(keyState); i++) { + if (keyState[i] != 0) { + allReleased = false; + break; + } + } + if (allReleased) break; + } + usleep(50000); + attempts++; + } + + if (ioctl(dev.fd, EVIOCGRAB, 1) == 0) { + dev.grabbed = true; + LOG_INFO(kTag, QString("已抓取: %1").arg(dev.path)); + } else { + LOG_WARNING(kTag, QString("抓取失败: %1").arg(dev.path)); + } + } + return true; + } + + /** 释放所有键盘抓取 */ + void ungrabAllKeyboards() { + for (auto& dev : keyboards) { + if (dev.grabbed) { + ioctl(dev.fd, EVIOCGRAB, 0); + dev.grabbed = false; + } + } + } + + /** 重放事件到 uinput */ + void replayEvent(uint16_t type, uint16_t code, int32_t value) { + if (uinputFd < 0) return; + struct input_event ev; + memset(&ev, 0, sizeof(ev)); + gettimeofday(&ev.time, nullptr); + ev.type = type; + ev.code = code; + ev.value = value; + write(uinputFd, &ev, sizeof(ev)); + } + + /** 处理单个键盘的事件 */ + void handleKeyboardEvents(KeyboardDevice& dev, QObject* parent) { + struct input_event ev; + while (true) { + ssize_t n = read(dev.fd, &ev, sizeof(ev)); + if (n < 0) break; + + if (ev.type != EV_KEY) { + // 非按键事件,重放 + replayEvent(ev.type, ev.code, ev.value); + continue; + } + + // value: 1=按下, 0=松开, 2=自动重复 + if (ev.value == 2) { + replayEvent(ev.type, ev.code, ev.value); + continue; + } + + // 更新修饰键状态 + switch (ev.code) { + case KEY_LEFTCTRL: + case KEY_RIGHTCTRL: + ctrlPressed = (ev.value == 1); + break; + case KEY_LEFTALT: + case KEY_RIGHTALT: + altPressed = (ev.value == 1); + break; + case KEY_LEFTSHIFT: + case KEY_RIGHTSHIFT: + shiftPressed = (ev.value == 1); + break; + case KEY_LEFTMETA: + case KEY_RIGHTMETA: + metaPressed = (ev.value == 1); + break; + } + + // 判断是否为配置的快捷键 + if (isHotkeyCombo(ev.code)) { + // 快捷键组合 — 不重放(抑制) + if (ev.value == 1) { + LOG_DEBUG(kTag, "快捷键按下 (evdev)"); + QMetaObject::invokeMethod(parent, "emitPressed", Qt::QueuedConnection); + } else { + LOG_DEBUG(kTag, "快捷键松开 (evdev)"); + QMetaObject::invokeMethod(parent, "emitReleased", Qt::QueuedConnection); + } + } else if (isModifierKey(ev.code)) { + // 单独的修饰键 — 正常重放 + replayEvent(ev.type, ev.code, ev.value); + } else { + // 其他按键 — 重放到虚拟设备 + replayEvent(ev.type, ev.code, ev.value); + } + } + } +}; + +EvdevCapsLockBackend::EvdevCapsLockBackend(QObject* parent) + : CapsLockBackend(parent) + , m_impl(std::make_unique()) +{} + +EvdevCapsLockBackend::~EvdevCapsLockBackend() { + stop(); +} + +void EvdevCapsLockBackend::setHotkeyCombo(int modifiers, int key) { + CapsLockBackend::setHotkeyCombo(modifiers, key); + + int evdevCode = qtKeyToEvdevCode(key); + m_impl->targetKeycode = evdevCode; + m_impl->targetModifiers = modifiers; + + if (m_active) { + stop(); + start(); + } +} + +bool EvdevCapsLockBackend::start() { + if (m_active) return true; + + // 检查快捷键配置 + int evdevCode = qtKeyToEvdevCode(m_key); + if (evdevCode == 0) { + LOG_ERROR(kTag, QString("不支持的按键 (key=0x%1)").arg(m_key, 0, 16)); + emit error("不支持的按键,请使用字母、数字或功能键"); + return false; + } + m_impl->targetKeycode = evdevCode; + m_impl->targetModifiers = m_modifiers; + + LOG_INFO(kTag, QString("快捷键配置: keycode=%1, modifiers=0x%2").arg(evdevCode).arg(m_modifiers, 0, 16)); + + // 检查 /dev/uinput 权限 + if (access("/dev/uinput", W_OK) != 0) { + LOG_ERROR(kTag, "/dev/uinput 不可写。请执行以下操作之一:\n" + " 1. sudo usermod -aG input $USER(然后重新登录)\n" + " 2. 创建 /etc/udev/rules.d/99-impress-keyboard.rules:\n" + " KERNEL==\"uinput\", MODE=\"0660\", GROUP=\"input\"\n" + " KERNEL==\"event*\", SUBSYSTEM==\"input\", MODE=\"0660\", GROUP=\"input\""); + emit error("/dev/uinput 权限不足,请配置 evdev 访问权限"); + return false; + } + + // 查找键盘 + m_impl->keyboards = m_impl->findKeyboards(); + if (m_impl->keyboards.isEmpty()) { + LOG_ERROR(kTag, "未找到键盘设备"); + return false; + } + + LOG_INFO(kTag, QString("找到 %1 个键盘设备").arg(m_impl->keyboards.size())); + + // 创建 uinput 虚拟设备 + if (!m_impl->createUinputDevice()) { + return false; + } + + // 抓取所有键盘 + m_impl->grabAllKeyboards(); + + // 为每个键盘创建 QSocketNotifier + for (auto& dev : m_impl->keyboards) { + if (!dev.grabbed) continue; + auto* notifier = new QSocketNotifier(dev.fd, QSocketNotifier::Read, this); + connect(notifier, &QSocketNotifier::activated, this, [this, &dev]() { + m_impl->handleKeyboardEvents(dev, this); + }); + } + + m_active = true; + emit ready(); + LOG_INFO(kTag, "全局快捷键已注册(evdev)"); + return true; +} + +void EvdevCapsLockBackend::stop() { + if (!m_active) return; + + m_impl->ungrabAllKeyboards(); + + if (m_impl->uinputFd >= 0) { + ioctl(m_impl->uinputFd, UI_DEV_DESTROY); + close(m_impl->uinputFd); + m_impl->uinputFd = -1; + } + + for (auto& dev : m_impl->keyboards) { + if (dev.fd >= 0) { + close(dev.fd); + dev.fd = -1; + } + } + m_impl->keyboards.clear(); + m_impl->ctrlPressed = false; + m_impl->altPressed = false; + m_impl->shiftPressed = false; + m_impl->metaPressed = false; + + m_active = false; + LOG_INFO(kTag, "全局快捷键已注销(evdev)"); +} + +} // namespace impress diff --git a/src/core/caps_lock_evdev.h b/src/core/caps_lock_evdev.h new file mode 100644 index 0000000..d2a152d --- /dev/null +++ b/src/core/caps_lock_evdev.h @@ -0,0 +1,45 @@ +#pragma once + +#include "caps_lock_backend.h" + +#include +#include + +namespace impress { + +/** + * @brief Wayland 后端:通过 evdev 直接读取键盘设备 + * + * 使用 EVIOCGRAB 抓取键盘 + uinput 重放非快捷键事件。 + * 需要 input 组权限或 udev 规则。 + * + * 权限设置(一次性): + * sudo usermod -aG input $USER + * 或创建 /etc/udev/rules.d/99-impress-keyboard.rules: + * KERNEL=="event*", SUBSYSTEM=="input", MODE="0660", GROUP="input" + * KERNEL=="uinput", MODE="0660", GROUP="input" + */ +class EvdevCapsLockBackend : public CapsLockBackend { + Q_OBJECT +public: + explicit EvdevCapsLockBackend(QObject* parent = nullptr); + ~EvdevCapsLockBackend() override; + + bool start() override; + void stop() override; + bool isActive() const override { return m_active; } + const char* backendName() const override { return "evdev"; } + + void setHotkeyCombo(int modifiers, int key) override; + +public slots: + void emitPressed() { emit pressed(); } + void emitReleased() { emit released(); } + +private: + struct Impl; + std::unique_ptr m_impl; + bool m_active = false; +}; + +} // namespace impress diff --git a/src/core/caps_lock_xcb.cpp b/src/core/caps_lock_xcb.cpp new file mode 100644 index 0000000..5a7a178 --- /dev/null +++ b/src/core/caps_lock_xcb.cpp @@ -0,0 +1,203 @@ +#include "caps_lock_xcb.h" +#include "utils/logger.h" + +#include +#include +#include + +#include +#include +#include + +static const char* const kTag = "XcbCapsLockBackend"; + +namespace impress { + +XcbCapsLockBackend::XcbCapsLockBackend(QObject* parent) + : CapsLockBackend(parent) +{} + +XcbCapsLockBackend::~XcbCapsLockBackend() { + stop(); +} + +void XcbCapsLockBackend::setHotkeyCombo(int modifiers, int key) { + CapsLockBackend::setHotkeyCombo(modifiers, key); + + // 如果已经在运行,需要重新注册 + if (m_active) { + stop(); + start(); + } +} + +bool XcbCapsLockBackend::start() { + if (m_active) return true; + + // 获取 X11 连接(需要 QGuiApplication 类型的指针来访问 nativeInterface) + auto* guiApp = dynamic_cast(QCoreApplication::instance()); + if (!guiApp) return false; + + auto* x11App = guiApp->nativeInterface(); + if (!x11App) { + LOG_INFO(kTag, "非 X11 会话,XGrabKey 不可用"); + return false; + } + + m_conn = x11App->connection(); + if (!m_conn) { + LOG_ERROR(kTag, "无法获取 XCB 连接"); + return false; + } + + // 获取根窗口(通过 XCB setup 信息) + auto* setup = xcb_get_setup(m_conn); + if (!setup || setup->roots_len == 0) { + LOG_ERROR(kTag, "无法获取 XCB 根窗口"); + return false; + } + m_rootWindow = xcb_setup_roots_iterator(setup).data->root; + + // 获取按键 keycode(XCB 没有直接的 keysym->keycode,用 Xlib 获取) + Display* dpy = XOpenDisplay(nullptr); + if (!dpy) { + LOG_ERROR(kTag, "无法打开 X Display"); + return false; + } + m_keycode = XKeysymToKeycode(dpy, KeySym(m_key)); + XCloseDisplay(dpy); + + if (m_keycode == 0) { + LOG_ERROR(kTag, QString("无法获取按键 keycode (key=0x%1)").arg(m_key, 0, 16)); + return false; + } + + LOG_INFO(kTag, QString("快捷键 keycode: %1 (modifiers=0x%2)").arg(m_keycode).arg(m_modifiers, 0, 16)); + + // 抓取快捷键 + if (!grabKey()) { + return false; + } + + // 安装 native event filter + QAbstractEventDispatcher::instance()->installNativeEventFilter(this); + + m_active = true; + emit ready(); + LOG_INFO(kTag, "全局快捷键已注册(X11-XGrabKey)"); + return true; +} + +void XcbCapsLockBackend::stop() { + if (!m_active) return; + + // 移除 native event filter + if (QAbstractEventDispatcher* dispatcher = QAbstractEventDispatcher::instance()) { + dispatcher->removeNativeEventFilter(this); + } + + ungrabKey(); + m_active = false; + LOG_INFO(kTag, "全局快捷键已注销(X11)"); +} + +bool XcbCapsLockBackend::grabKey() { + // 需要用所有 modifier 组合来抓取 + // 包括: 无修饰、NumLock(Mask2)、Lock(Mask1)、Mode_switch(Mask5) 等 + const uint16_t masks[] = { + 0, // 无修饰 + XCB_MOD_MASK_2, // NumLock + XCB_MOD_MASK_1, // Lock + XCB_MOD_MASK_1 | XCB_MOD_MASK_2, // Lock + NumLock + XCB_MOD_MASK_5, // Mode_switch + XCB_MOD_MASK_2 | XCB_MOD_MASK_5, // NumLock + Mode_switch + XCB_MOD_MASK_1 | XCB_MOD_MASK_5, // Lock + Mode_switch + XCB_MOD_MASK_1 | XCB_MOD_MASK_2 | XCB_MOD_MASK_5, // 全部 + }; + + // Qt modifier → XCB modifier mask + uint16_t baseMask = 0; + if (m_modifiers & Qt::ShiftModifier) baseMask |= XCB_MOD_MASK_SHIFT; + if (m_modifiers & Qt::ControlModifier) baseMask |= XCB_MOD_MASK_CONTROL; + if (m_modifiers & Qt::AltModifier) baseMask |= XCB_MOD_MASK_1; // Alt is typically Mod1 + if (m_modifiers & Qt::MetaModifier) baseMask |= XCB_MOD_MASK_4; // Meta is typically Mod4 (Super) + + for (uint16_t mask : masks) { + uint16_t combined = baseMask | mask; + + xcb_void_cookie_t cookie = xcb_grab_key_checked( + m_conn, + 1, // owner_events = true + m_rootWindow, // 在根窗口上抓取 + combined, // 修饰掩码 + m_keycode, // 按键码 + XCB_GRAB_MODE_ASYNC, // pointer mode + XCB_GRAB_MODE_ASYNC // keyboard mode + ); + + xcb_generic_error_t* error = xcb_request_check(m_conn, cookie); + if (error) { + // 某些 modifier 组合可能已被其他程序占用,忽略 + LOG_DEBUG(kTag, QString("grab modifier %1 失败 (code=%2)") + .arg(combined).arg(error->error_code)); + free(error); + } + } + + return true; +} + +void XcbCapsLockBackend::ungrabKey() { + if (!m_conn || !m_keycode) return; + + const uint16_t masks[] = { + 0, + XCB_MOD_MASK_2, + XCB_MOD_MASK_1, + XCB_MOD_MASK_1 | XCB_MOD_MASK_2, + XCB_MOD_MASK_5, + XCB_MOD_MASK_2 | XCB_MOD_MASK_5, + XCB_MOD_MASK_1 | XCB_MOD_MASK_5, + XCB_MOD_MASK_1 | XCB_MOD_MASK_2 | XCB_MOD_MASK_5, + }; + + uint16_t baseMask = 0; + if (m_modifiers & Qt::ShiftModifier) baseMask |= XCB_MOD_MASK_SHIFT; + if (m_modifiers & Qt::ControlModifier) baseMask |= XCB_MOD_MASK_CONTROL; + if (m_modifiers & Qt::AltModifier) baseMask |= XCB_MOD_MASK_1; + if (m_modifiers & Qt::MetaModifier) baseMask |= XCB_MOD_MASK_4; + + for (uint16_t mask : masks) { + xcb_ungrab_key(m_conn, m_keycode, m_rootWindow, baseMask | mask); + } + xcb_flush(m_conn); +} + +bool XcbCapsLockBackend::nativeEventFilter(const QByteArray& eventType, + void* message, qintptr*) { + if (!m_active || eventType != "xcb_generic_event_t") + return false; + + auto* ev = static_cast(message); + uint8_t responseType = ev->response_type & ~0x80; + + if (responseType == XCB_KEY_PRESS) { + auto* kev = reinterpret_cast(ev); + if (kev->detail == m_keycode) { + LOG_DEBUG(kTag, "快捷键按下 (X11)"); + emit pressed(); + return true; // 阻止事件传播 + } + } else if (responseType == XCB_KEY_RELEASE) { + auto* kev = reinterpret_cast(ev); + if (kev->detail == m_keycode) { + LOG_DEBUG(kTag, "快捷键松开 (X11)"); + emit released(); + return true; // 阻止事件传播 + } + } + + return false; +} + +} // namespace impress diff --git a/src/core/caps_lock_xcb.h b/src/core/caps_lock_xcb.h new file mode 100644 index 0000000..38fab1b --- /dev/null +++ b/src/core/caps_lock_xcb.h @@ -0,0 +1,46 @@ +#pragma once + +#include "caps_lock_backend.h" + +#include +#include + +struct xcb_connection_t; + +typedef unsigned int xcb_window_t; +typedef unsigned char xcb_keycode_t; + +namespace impress { + +/** + * @brief X11 后端:使用 XCB XGrabKey 捕获全局快捷键 + * + * 零权限,在 X11 下工作良好。Wayland 下不可用。 + * 支持可配置的快捷键组合(如 Ctrl+Alt+C)。 + */ +class XcbCapsLockBackend : public CapsLockBackend, public QAbstractNativeEventFilter { +public: + explicit XcbCapsLockBackend(QObject* parent = nullptr); + ~XcbCapsLockBackend() override; + + bool start() override; + void stop() override; + bool isActive() const override { return m_active; } + const char* backendName() const override { return "X11-XGrabKey"; } + + void setHotkeyCombo(int modifiers, int key) override; + + bool nativeEventFilter(const QByteArray& eventType, void* message, + qintptr* result) override; + +private: + bool grabKey(); + void ungrabKey(); + + xcb_connection_t* m_conn = nullptr; + xcb_window_t m_rootWindow = 0; + xcb_keycode_t m_keycode = 0; + bool m_active = false; +}; + +} // namespace impress diff --git a/src/ui/widgets/recording_indicator.cpp b/src/ui/widgets/recording_indicator.cpp new file mode 100644 index 0000000..3f64d8b --- /dev/null +++ b/src/ui/widgets/recording_indicator.cpp @@ -0,0 +1,131 @@ +#include "recording_indicator.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace impress { + +struct RecordingIndicator::Impl { + QLabel* iconLabel = nullptr; + QLabel* textLabel = nullptr; + QTimer* blinkTimer = nullptr; + bool iconBlinkState = true; + + /** 创建录音中图标(红色三角形 + 脉冲动画) */ + QPixmap createRecordingIcon(int size) { + QPixmap pixmap(size, size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing); + + const int margin = size / 8; + const int s = size - margin * 2; + + // 红色圆点(背景脉冲) + painter.setPen(Qt::NoPen); + QColor dotColor = iconBlinkState ? QColor(231, 76, 60) : QColor(192, 57, 43); + painter.setBrush(dotColor); + painter.drawEllipse(QPoint(size / 2, size / 2), s / 2, s / 2); + + // 白色三角形(播放图标) + painter.setBrush(Qt::white); + const int triMargin = size / 3; + QPolygon triangle; + triangle << QPoint(triMargin, triMargin) + << QPoint(triMargin, size - triMargin) + << QPoint(size - triMargin, size / 2); + painter.drawPolygon(triangle); + + return pixmap; + } +}; + +RecordingIndicator::RecordingIndicator(QWidget* parent) + : QWidget(parent, Qt::FramelessWindowHint | Qt::Tool | Qt::WindowStaysOnTopHint | Qt::NoDropShadowWindowHint) + , impl_(std::make_unique()) +{ + setAttribute(Qt::WA_TranslucentBackground); + setAttribute(Qt::WA_ShowWithoutActivating); + + createUI(); +} + +RecordingIndicator::~RecordingIndicator() = default; + +void RecordingIndicator::createUI() { + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(6, 4, 6, 4); + layout->setSpacing(6); + + impl_->iconLabel = new QLabel(this); + impl_->iconLabel->setFixedSize(24, 24); + layout->addWidget(impl_->iconLabel); + + impl_->textLabel = new QLabel("录音中...", this); + impl_->textLabel->setStyleSheet( + "QLabel { color: #e74c3c; font-size: 13px; font-weight: bold; }"); + layout->addWidget(impl_->textLabel); + + // 闪烁动画 + impl_->blinkTimer = new QTimer(this); + impl_->blinkTimer->setInterval(500); + connect(impl_->blinkTimer, &QTimer::timeout, this, [this]() { + impl_->iconBlinkState = !impl_->iconBlinkState; + updateIcon(true); + }); +} + +void RecordingIndicator::updateIcon(bool) { + if (impl_->iconLabel) { + impl_->iconLabel->setPixmap(impl_->createRecordingIcon(24)); + } +} + +void RecordingIndicator::showAtCursor() { + if (isShowing_) return; + + QPoint pos = cursorGlobalPosition(); + + // 偏移:显示在光标右下方 + pos += QPoint(16, 16); + + // 确保不超出屏幕 + QScreen* screen = QApplication::screenAt(pos); + if (!screen) screen = QApplication::primaryScreen(); + + QRect geo = screen->availableGeometry(); + if (pos.x() + width() > geo.right()) { + pos.setX(geo.right() - width()); + } + if (pos.y() + height() > geo.bottom()) { + pos.setY(geo.bottom() - height()); + } + + move(pos); + updateIcon(true); + show(); + isShowing_ = true; + + // 启动闪烁 + impl_->blinkTimer->start(); +} + +void RecordingIndicator::hide() { + if (!isShowing_) return; + + impl_->blinkTimer->stop(); + QWidget::hide(); + isShowing_ = false; +} + +QPoint RecordingIndicator::cursorGlobalPosition() const { + return QCursor::pos(); +} + +} // namespace impress diff --git a/src/ui/widgets/recording_indicator.h b/src/ui/widgets/recording_indicator.h new file mode 100644 index 0000000..84a549f --- /dev/null +++ b/src/ui/widgets/recording_indicator.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include + +namespace impress { + +/** + * @brief 录音指示器 — 光标位置浮动窗口 + * + * 快捷键触发时在鼠标光标位置显示一个小的录音指示器, + * 使用和系统托盘一致的动态图标(播放三角形 = 录音中)。 + */ +class RecordingIndicator : public QWidget { + Q_OBJECT +public: + explicit RecordingIndicator(QWidget* parent = nullptr); + ~RecordingIndicator() override; + + /** @brief 在鼠标位置显示指示器 */ + void showAtCursor(); + + /** @brief 隐藏指示器 */ + void hide(); + + /** @brief 是否正在显示 */ + bool isVisible() const { return isShowing_; } + +private: + void createUI(); + void updateIcon(bool active); + QPoint cursorGlobalPosition() const; + + struct Impl; + std::unique_ptr impl_; + bool isShowing_ = false; +}; + +} // namespace impress