普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月30日首页

Tauri(十九)——实现 macOS 划词监控的完整实践

2025年11月30日 16:04

背景

为了提高 Coco AI 的用户使用率,以及提供快捷操作等,我给我们 Coco AI 也增加了划词功能。

image.png

接下来就介绍一下如何在 Tauri v2 中实现“划词”功能(选中文本的实时检测与前端弹窗联动),覆盖 macOS 无障碍权限、坐标转换、多屏支持、前端事件桥接与性能/稳定性策略。

功能概述

  • 在系统前台 App 中选中文本后,后端读取选区文本与鼠标坐标,通过事件主动推给前端。
  • 前端根据事件展示/隐藏弹窗(或“快查”面板),并在主窗口中同步输入/状态。
  • 提供“全局开关”,随时启停划词监控。

image.png

关键点与设计思路

  • 权限:macOS 读取选区依赖系统“无障碍(Accessibility)”权限;首次运行时请求用户授权。
  • 稳定性:对选区读取做轻量重试与去抖,避免弹窗闪烁。
  • 坐标:Quartz 坐标系为“左下角为原点”,前端常用“左上角为原点”;需要对 y 做翻转。
  • 多屏:在多显示器场景下,根据鼠标所在显示器与全局边界计算统一坐标。
  • 交互保护:当 Coco 自己在前台时,暂不读取选区,避免把弹窗交互误判为空选区。
  • 事件协议:统一向前端发两个事件:
    • selection-detected:选区文本与坐标(或空字符串表示隐藏)
    • selection-enabled:开关状态

后端实现(Tauri v2 / Rust)

  • 定义事件载荷与全局开关,导出命令给前端调用。
  • 在启动入口中开启监控线程,不断读取选区并发事件。
/// 事件载荷:选中文本与坐标(逻辑点、左上为原点)
#[derive(serde::Serialize, Clone)]
struct SelectionEventPayload {
    text: String,
    x: i32,
    y: i32,
}

use std::sync::atomic::{AtomicBool, Ordering};

/// 全局开关:默认开启
static SELECTION_ENABLED: AtomicBool = AtomicBool::new(true);

#[derive(serde::Serialize, Clone)]
struct SelectionEnabledPayload {
    enabled: bool,
}

/// 读写开关并广播
pub fn is_selection_enabled() -> bool { SELECTION_ENABLED.load(Ordering::Relaxed) }
fn set_selection_enabled_internal(app_handle: &tauri::AppHandle, enabled: bool) {
    SELECTION_ENABLED.store(enabled, Ordering::Relaxed);
    let _ = app_handle.emit("selection-enabled", SelectionEnabledPayload { enabled });
}

/// Tauri 命令:供前端调用开关
#[tauri::command]
pub fn set_selection_enabled(app_handle: tauri::AppHandle, enabled: bool) {
    set_selection_enabled_internal(&app_handle, enabled);
}
#[tauri::command]
pub fn get_selection_enabled() -> bool { is_selection_enabled() }
  • 启动监控线程:权限校验、选区读取、坐标转换与事件发送。
#[cfg(target_os = "macos")]
pub fn start_selection_monitor(app_handle: tauri::AppHandle) {
    use std::time::Duration;
    use tauri::Emitter;

    // 同步初始开关状态到前端
    set_selection_enabled_internal(&app_handle, is_selection_enabled());

    // 申请/校验无障碍权限(macOS)
    {
        let trusted_before = macos_accessibility_client::accessibility::application_is_trusted();
        if !trusted_before {
            let _ = macos_accessibility_client::accessibility::application_is_trusted_with_prompt();
        }
        let trusted_after = macos_accessibility_client::accessibility::application_is_trusted();
        if !trusted_after {
            return; // 未授权则不启动监控
        }
    }

    // 监控线程
    std::thread::spawn(move || {
        use objc2_core_graphics::CGEvent;
        use objc2_core_graphics::{CGDisplayBounds, CGGetActiveDisplayList, CGMainDisplayID};
        #[cfg(target_os = "macos")]
        use objc2_app_kit::NSWorkspace;

        // 计算鼠标全局坐标(左上原点),并做 y 翻转
        let current_mouse_point_global = || -> (i32, i32) {
            unsafe {
                let event = CGEvent::new(None);
                let pt = objc2_core_graphics::CGEvent::location(event.as_deref());
                // 多屏取全局边界并翻转 y
                // ...(详见源码的显示器遍历与边界计算)
                // 返回 (x_top_left, y_flipped)
                // ... existing code ...
                (/*x*/0, /*y*/0)
            }
        };

        // Coco 在前台时不读选区,避免交互中误判空
        let is_frontmost_app_me = || -> bool {
            #[cfg(target_os = "macos")]
            unsafe {
                let workspace = NSWorkspace::sharedWorkspace();
                if let Some(frontmost) = workspace.frontmostApplication() {
                    let pid = frontmost.processIdentifier();
                    let my_pid = std::process::id() as i32;
                    return pid == my_pid;
                }
            }
            false
        };

        // 状态机与去抖
        let mut popup_visible = false;
        let mut last_text = String::new();
        let stable_threshold = 2; // 连续一致≥2次视为稳定
        let empty_threshold = 2;  // 连续空≥2次才隐藏
        let mut stable_text = String::new();
        let mut stable_count = 0;
        let mut empty_count = 0;

        loop {
            std::thread::sleep(Duration::from_millis(30));

            if !is_selection_enabled() {
                if popup_visible {
                    let _ = app_handle.emit("selection-detected", "");
                    popup_visible = false;
                    last_text.clear();
                    stable_text.clear();
                }
                continue;
            }

            let front_is_me = is_frontmost_app_me();
            let selected_text = if front_is_me {
                None // 交互期间不读选区
            } else {
                read_selected_text_with_retries(2, 35) // 轻量重试
            };

            match selected_text {
                Some(text) if !text.is_empty() => {
                    // 稳定性检测
                    // ... existing code ...
                    if stable_count >= stable_threshold {
                        if !popup_visible || text != last_text {
                            let (x, y) = current_mouse_point_global();
                            let payload = SelectionEventPayload { text: text.clone(), x, y };
                            let _ = app_handle.emit("selection-detected", payload);
                            last_text = text;
                            popup_visible = true;
                        }
                    }
                }
                _ => {
                    // 非前台且空选区:累计空次数后隐藏
                    // ... existing code ...
                }
            }
        }
    });
}
  • 读取选区(AXUIElement):优先系统级焦点,其次前台 App 的焦点/窗口;仅读取 AXSelectedText
#[cfg(target_os = "macos")]
fn read_selected_text() -> Option<String> {
    use objc2_application_services::{AXError, AXUIElement};
    use objc2_core_foundation::{CFRetained, CFString, CFType};
    // 优先系统级焦点 AXFocusedUIElement,失败则回退到前台 App/窗口焦点
    // 跳过当前进程(Coco)避免误判
    // 成功后读取 AXSelectedText,转为 String 返回
    // ... existing code ...
    Some(/*selected text*/ String::new())
}

#[cfg(target_os = "macos")]
fn read_selected_text_with_retries(retries: u32, delay_ms: u64) -> Option<String> {
    // 最多重试 N 次:缓解 AX 焦点短暂不稳定
    // ... existing code ...
    None
}

前端事件桥接

  • 事件名称

    • selection-enabled:载荷 { enabled: boolean },用于同步开关状态
    • selection-detected:载荷 { text: string, x: number, y: number }""(隐藏)
  • 监听与联动建议

    • 通过 platformAdapter.listenEvent("selection-detected", ...) 已完成桥接。
    • 收到带文本的事件后,渲染弹窗;收到 "" 时隐藏。
    • 在主窗口中同步搜索/聊天输入与模式。例如配合 useSearchStore/useAppStore 更新 searchValueisChatModeaskAiMessage 等。
// 伪示例:监听 selection-detected 并联动 UI
function useListenSelection() {
  // ... existing code ...
  platformAdapter.listenEvent("selection-detected", (payload) => {
    if (payload === "") {
      // 隐藏弹窗
      // ... existing code ...
      return;
    }
    const { text, x, y } = payload as { text: string; x: number; y: number };
    // 展示弹窗(使用 x, y 定位)
    // 同步到主窗口输入或 AI 询问
    // ... existing code ...
  });
}

Tauri v2 集成与命令注册

  • 在后端入口(如 main.rs):
    • 注册命令:set_selection_enabledget_selection_enabled
    • 应用启动后调用一次 start_selection_monitor(app_handle.clone()) 开启监控线程
fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            set_selection_enabled,
            get_selection_enabled
        ])
        .setup(|app| {
            let handle = app.handle().clone();
            #[cfg(target_os = "macos")]
            {
                start_selection_monitor(handle);
            }
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running coco app");
}

权限与配置

  • macOS 无障碍(Accessibility)权限
    • 首次启动会触发系统授权提示;用户需在“系统设置 → 隐私与安全 → 辅助功能”中允许 Coco。
    • 代码中使用 macos_accessibility_client 检查与提示,不需额外 Info.plist 键。
  • Tauri v2 Capabilities
    • Tauri 对前端 API 能力有更细粒度的限制;如需事件、命令调用等,确保 tauri.conf.jsoncapabilities 配置允许相应操作。

稳定性与性能策略

  • 去抖与重试
    • stable_threshold = 2:相同文本稳定两次再触发事件,减少闪烁与误报
    • empty_threshold = 2:空选区累计两次再隐藏,避免短暂抖动导致过度隐藏
  • 轮询间隔
    • 30ms 足够流畅,实际可根据功耗与体验权衡调整
  • 交互保护
    • 前台为 Coco 时不读选区,避免把弹窗交互过程误读为空选区,从而误触隐藏

坐标与多屏支持

  • Quartz 坐标系为“左下为原点”,很多前端布局为“左上为原点”
    • 通过计算全局高度并翻转 y,确保前端定位直观
  • 多屏场景
    • 遍历所有活动显示器,计算全局最左、最上、最下边界,统一映射全局坐标
    • 根据鼠标实际所在显示器确定相对坐标,兼顾跨屏切换的平滑性

常见问题与排查

  • 未授权导致“没有任何事件”
    • 检查“系统设置 → 隐私与安全 → 辅助功能”是否勾选 Coco
  • 前端没有响应 selection-detected
    • 确认事件监听正确(命名与载荷形态)、确保主窗口同步更新输入与模式
  • 坐标不正确或弹窗偏移
    • 排查坐标系转换(y 翻转)、多屏边界计算是否符合实际布局
  • 弹窗闪烁或频繁隐藏
    • 调整 stable_threshold / empty_threshold 与轮询间隔;也可对文本变化设更严格的稳定条件

测试清单

  • 授权流程:首次运行提示、授权后是否正常读取
  • 多屏场景:跨屏移动鼠标后坐标是否正确、弹窗位置是否稳定
  • 交互过程:点击弹窗与主窗口时是否停止读取选区、不会误判空而隐藏
  • 文本变化:快速划词切换时是否平滑、不会频繁闪烁

小结

  • 划词功能的核心在于 “权限 → 获取选区 → 稳定性处理 → 事件联动 → 前端渲染” 这条链路。
  • Tauri v2 在能力管理与事件桥接上更清晰,结合 macOS 的 AX 接口与坐标转换,可以构建稳定、体验良好的系统级“快查”能力。

开源共建,欢迎 Star ✨:github.com/infinilabs/…

🎨 用一次就爱上的图标定制体验:CustomIcons 实战

2025年11月29日 22:31

在前端项目里,图标不是“点缀”,它往往是信息结构与互动线索的关键。如何让图标既统一又可配、既美观又可国际化?这篇文章带你用 @infinilabs/custom-icons 打造一套“可配置、可主题、可国际化”的图标解决方案。

背景

  • Coco AI(开源) 用户需要可以配置自定义 Icon。
  • 多品牌与多区域:同一产品在不同客户、不同区域需要差异化的风格与语言。
  • 设计与工程协作:设计希望图标统一;工程需要灵活调整尺寸、颜色、类型、甚至自定义图片。
  • 运营与配置:希望在管理面板里直接挑选或调整图标,而不是改代码、发版本。

于是,我做了一个轻量、直观、开箱即用的组件库:@infinilabs/custom-icons

适用场景

  • 可视化配置台:在后台面板中为功能、菜单或模块选择与配置图标。
  • 多主题产品:快速切换深色/浅色主题,保证图标在不同背景下的对比度与风格。
  • 国际化应用:在不同语言环境下自动切换文案与控件标签。
  • 自定义品牌:支持上传自定义图片作为图标,满足品牌个性化需求。

主要能力

  • 图标渲染组件:ConfigurableIcon
    • 指定类型(如 lucide)、图标名、颜色与尺寸即可渲染。
    • 支持数据 URL(自定义图片)模式。
  • 图标选择器:IconPicker
    • 一站式选择与配置:类型、名称、尺寸、颜色与图片上传。
    • 可选主题与国际化支持。
    • 可通过 controls 精细开关各子控件。

快速开始

# 使用你熟悉的包管理器安装
pnpm add @infinilabs/custom-icons
# 或
npm i @infinilabs/custom-icons
# 或
yarn add @infinilabs/custom-icons

在项目中引用:

import { useState } from "react";
import { ConfigurableIcon, IconPicker } from "@infinilabs/custom-icons";

export default function Demo() {
  const [config, setConfig] = useState({
    type: "lucide",
    name: "Bot",
    size: 28,
    color: "#1e90ff",
    dataUrl: undefined,
  });

  return (
    <div style={{ padding: 24 }}>
      {/* 渲染当前配置的图标 */}
      <ConfigurableIcon
        type={config.type}
        name={config.name}
        size={config.size}
        color={config.color}
        dataUrl={config.dataUrl}
      />

      {/* 交互式选择与配置 */}
      <IconPicker
        value={config}
        onChange={setConfig}
        configurable
        theme="light"
        locale="zh-CN"
        controls={{
          type: true,
          name: true,
          size: true,
          color: true,
          image: true,
        }}
      />
    </div>
  );
}

如果你需要查看可选的 Lucide 图标名称,选择器旁已内置快捷链接:

基础效果

image.png

组件详解

ConfigurableIcon

用于在任意位置渲染一个图标。

  • 关键属性
    • type: 图标类型(如 lucide 或自定义)
    • name: 图标名称(type=lucide 时为 Lucide 名称)
    • size: 数值尺寸(px)
    • color: 颜色(十六进制或 CSS 颜色)
    • dataUrl: 当使用自定义图片时的 data: URL

示例(自定义图片):

<ConfigurableIcon
  type="custom"
  name="my-logo"
  dataUrl="data:image/png;base64,...."
  size={28}
  color="#1e90ff" // 自定义图片时通常忽略颜色
/>

IconPicker

一个将预览与配置控件整合在一起的选择器。可插在设置面板或表单中,让用户自行挑选或上传。

  • 常用属性

    • value: 当前图标配置对象
    • onChange(next): 配置变化回调
    • configurable: 是否展示配置面板
    • controls: 控件开关集合(type/name/size/color/image 等)
    • theme: light | dark
    • locale: zh-CN | en-US
    • i18n: 文案对象(可覆盖默认文案)
  • 控件开关示例

<IconPicker
  value={config}
  onChange={setConfig}
  configurable
  controls={{
    type: true,
    name: true,
    size: true,
    color: true,
    image: true, // 打开即出现上传控件
  }}
/>
  • 主题与国际化
<IconPicker
  value={config}
  onChange={setConfig}
  configurable
  theme="dark"
  locale="en-US"
/>

进阶示例:面板内批量配置

将多个图标配置成一组,供菜单或卡片模块统一管理:

function IconsPanel() {
  const [items, setItems] = useState([
    { id: 1, config: { type: "lucide", name: "Home", size: 24, color: "#444" } },
    { id: 2, config: { type: "lucide", name: "Settings", size: 24, color: "#444" } },
  ]);

  const updateItem = (id, next) =>
    setItems((prev) =>
      prev.map((it) => (it.id === id ? { ...it, config: next } : it))
    );

  return (
    <div style={{ display: "grid", gap: 16 }}>
      {items.map((it) => (
        <div key={it.id} style={{ padding: 12, border: "1px solid #eee", borderRadius: 8 }}>
          <ConfigurableIcon {...it.config} />
          <IconPicker
            value={it.config}
            onChange={(next) => updateItem(it.id, next)}
            configurable
            theme="light"
            locale="zh-CN"
            controls={{ type: true, name: true, size: true, color: true, image: false }}
          />
        </div>
      ))}
    </div>
  );
}

设计与工程协作建议

  • 设计提供命名规范:例如统一使用 Lucide 的图标名集合,避免随意命名。
  • 管理面板适配:通过 controls 开关不同角色看到的控件(运营只改颜色与大小、开发可修改类型与名称)。
  • 主题变量托管:将颜色与尺寸作为“设计令牌”,统一管理与回收。

常见问题

  • 自定义图片会应用颜色吗?
    • 通常不会;颜色更适用于矢量图标。自定义图片由图片本身决定视觉。
  • 如何选择 Lucide 图标名?
    • 打开 https://lucide.dev/icons/,在选择器里输入对应名称即可。

image.png

小结

@infinilabs/custom-icons 让“图标即配置”的能力落地:从主题与国际化,到自定义图片与统一风格,既能保证设计一致性,又给予业务足够自由度。把它接入你的管理面板或设置页,让图标成为产品的强大表达力,而不是维护负担。

如果你对更多场景(如基于角色的控件可见性、图标库扩展)有想法,欢迎继续交流与共建!

开源共建:github.com/infinilabs/…

❌
❌