简介基于C语言实现的Linux屏幕取词翻译源码包面向需要在终端或文本界面中快速取词翻译的Linux用户也适合希望深入理解屏幕取词、翻译API集成与CLI/GUI开发细节的C语言开发者。源码覆盖取词、翻译、展示等核心环节同时支持Bing与Google两套翻译服务并包含用户界面、配置控制、快捷键监听等实用模块。压缩包共141个文件约16.7MB主体为C源码40个.c和25个.h与Makefile构建脚本另含PNG/GIF演示图片、Shell辅助脚本、界面定义文件、README、许可证与Git子模块配置等目录结构清晰便于按模块检索。目前已有122人学习参考。借助这套源码读者可以梳理Linux下鼠标或窗口取词的实现路径学习第三方翻译接口的接入方式还能参考其配置管理、界面分层与模块划分思路适合作为C语言项目实战和Linux桌面工具开发的进阶素材。 做了几个周末用C语言在Linux上写了一个屏幕取词翻译的小工具。起因很朴素我每天有大量时间泡在终端里读英文man page、翻开源代码注释、偶尔还要过一遍英文技术网页标准动作永远是选中、复制、切到浏览器、粘贴、点翻译再切回来。一天重复几十次真的消磨耐心。Windows下还行Linux上想找个鼠标选中、自动出译文的轻量工具却一直不顺心要么是Python脚本套了一堆依赖要么只适配Gnome或KDE的某几个版本要么用Electron把内存吃到让人肉疼。既然找不到趁手的干脆自己实现一个也顺带把X11的Selection机制彻底吃透。选C语言几乎是本能反应这工具常驻后台要求启动快、占用低、行为可预期C语言配合Xlib和GTK3正好能覆盖全部需求。代码不多拆成选区监听、翻译请求、悬浮窗三个模块逻辑清晰也容易扩展。这篇文章就把整个实现过程完整记录下来从X11选区机制的原理讲到C代码怎么写再到翻译API怎么接、GTK弹窗怎么调最后是联调阶段踩过的几个大坑。懂一点C语言和基础网络编程的朋友顺着这篇文章基本能把整个工具完整复现出来。1. 一个朴素的原型先明确工具要满足什么需求动手之前先把需求限定清楚。我要做的是屏幕取词翻译和市面上划词翻译剪贴板翻译最大的区别在于用户不需要按CtrlC复制只需要用鼠标选中文字工具就要自动感知这次选中并给出翻译结果。这个交互方式决定了它背后必须有一个常驻进程在后台不断感知用户选中了什么。需求收敛之后只有三条选中即译鼠标选中任意单词或短句自动弹出悬浮框显示翻译不需要任何快捷键。轻量常驻进程常驻后台内存占用尽量低不挑桌面环境X11下就能跑。翻译可靠优先走在线翻译API但要有本地缓存同一个词不能反复请求浪费网络。模块划分也随之确定下来selection模块负责监听X11的选区变化并取回选中文本translator模块负责请求翻译API并解析结果popup模块负责用GTK弹出无焦点悬浮窗。main程序把三个模块用两个线程串起来主线程跑GTK事件循环子线程跑选区监听和翻译请求线程间通过回调机制交换数据。很多人一开始会把注意力放在怎么翻译上实际上这个项目最难的部分根本不在这里。真正的核心难点在取词端怎么稳定、高效地感知用户每次选中并且拿到干净的选中文本。翻译接口反而简单无脑HTTP请求加解析就够了。这也是我坚持用C语言而不是随便套个脚本的原因——X11底层的选区交互只有通过Xlib/XCB这类原生接口才控制得最细腻。2. X11选区机制屏幕上的选中在Linux底层是什么2.1 PRIMARY与CLIPBOARD两个完全不同的选区在X11的世界里选中文字不是即时拷贝到某块内存而是通过一种叫Selection选区的机制实现。简单理解当你在浏览器里用鼠标拖选一个单词后那个程序会向X Server宣布我拥有PRIMARY选区里面是这段文字。其他程序如果想知道当前选中的是什么就得向这个所有者程序发送请求请它把内容交出来。X11有两个最常用的选区PRIMARY和CLIPBOARD。PRIMARY对应的是鼠标直接选中相当于某些系统里的高亮即复制CLIPBOARD对应的是CtrlC复制。GUI程序在选中文字时一般不会动CLIPBOARD只有显式按了复制键才写入但几乎所有程序都会在选中时立刻声明PRIMARY。因此做屏幕取词必须监听PRIMARY而不是CLIPBOARD否则取到的会是用户上一次主动复制的旧内容。有个非常容易踩的误区是在控制台、SSH终端里选中文字时行为与X GUI程序不完全一样。终端应用通常会直接把选中文本同时写入PRIMARY但有些还在用老的X Selection机制表现为选中后如果马上切换工作区内容可能就取不到了。这个细节我放到后面踩坑部分详细说。2.2 一次取词请求的完整链路当我们的程序要获取当前PRIMARY选区的内容时流程是这样的调用XGetSelectionOwner拿到当前PRIMARY选区的所有者窗口。调用XConvertSelection告诉X Server请把PRIMARY选区的内容转换成UTF-8然后放到我指定的窗口属性里。我们自己的程序进入等待状态收到SelectionNotify事件后再通过XGetWindowProperty从属性中读出真正的文本。注意XConvertSelection本身是不阻塞的它只是一次异步请求。请求发给拥有PRIMARY选区的那个程序后对方可以立即响应也可以过一会儿再响应极端情况下甚至可以不响应。所以获取选区的代码必须放在一个自己能控制的事件循环里不能直接去等Xlib内部事件否则会把整个程序卡住。2.3 为什么取词监听通常选择轮询而不是事件理论上X11没有提供选区内容被修改这一类的通用事件通知你没法注册一个回调说用户一旦选了新文字就通知我。常见的实现方案是轮询每隔几百毫秒检查一次PRIMARY选区的所有者窗口是否变化如果owner变了说明用户选中了新内容这时再发起一次内容转换请求。很多第一次接触X11开发的读者会纠结轮询是不是太笨了实际写下来发现300毫秒的轮询间隔已经在实时响应和CPU占用之间取得了很好的平衡一次XGetSelectionOwner的开销极小。我在这版代码里用的就是轮询owner变化检测的策略实测选中文字后不到200毫秒就能弹出翻译框感知足够灵敏进程的CPU占用率几乎可以忽略。3. C语言监听选区数据结构与核心代码设计3.1 选区监听模块的核心结构体我定义了一个SelectionMonitor结构体来管理所有选区相关的状态包括X11连接、常用Atom、上一次的owner以及缓存中的当前选中文本typedef struct { Display *display; Window root; Atom utf8_atom; /* UTF8_STRING */ Atom primary_atom; /* PRIMARY */ Window last_owner; /* 上一次的选区owner用于检测变化 */ char current[1024]; /* 当前选中的文本 */ bool has_text; /* 是否已经取到过内容 */ } SelectionMonitor;初始化时只需要打开X Display获取对应Atom即可。注意UTF8_STRING这个Atom是后来X.Org扩展出来的老代码里经常直接请求XA_STRING即Latin-1编码会导致非英文字符乱码这里一定要用UTF8_STRING取回中文时才不会出问题。bool selection_monitor_init(SelectionMonitor *sm) { sm-display XOpenDisplay(NULL); if (!sm-display) return false; sm-root DefaultRootWindow(sm-display); sm-utf8_atom XInternAtom(sm-display, UTF8_STRING, False); sm-primary_atom XInternAtom(sm-display, PRIMARY, False); sm-last_owner None; sm-has_text false; sm-current[0] \0; return true; }3.2 获取选中文本XConvertSelection与读取属性核心的取词函数负责发起转换请求并等待SelectionNotify事件。这里有几个关键点请求要发送到根窗口并指定一个我们自己约定的结果属性名等待事件时要兼容同窗口的其他事件不能因为收到一个不相关的SelectionNotify就提前退出。char *selection_get_text(SelectionMonitor *sm) { Atom result_prop XInternAtom(sm-display, SEL_RESULT, False); XConvertSelection(sm-display, sm-primary_atom, sm-utf8_atom, result_prop, sm-root, CurrentTime); XFlush(sm-display); XEvent ev; while (true) { XNextEvent(sm-display, ev); if (ev.type ! SelectionNotify) continue; if (ev.xselection.property None) { /* 对方拒绝了转换请求 */ return NULL; } Atom actual_type; int actual_format; unsigned long nitems, bytes_after; unsigned char *data NULL; int rc XGetWindowProperty(sm-display, sm-root, result_prop, 0, 4096, True, AnyPropertyType, actual_type, actual_format, nitems, bytes_after, data); if (rc ! Success || data NULL) return NULL; char *result strdup((const char *)data); XFree(data); return result; } }这个代码有个使用前提必须把取词逻辑放到独立的线程里因为它内部的XNextEvent会阻塞等待。如果直接放在GTK主线程里一旦选中文字时对方程序响应慢界面就会跟着卡住。我的做法是开一个pthread线程专门跑选区监听循环取到新文本后通过回调通知主线程。3.3 编码处理与文本清洗X11里的文本编码是个容易翻车的地方。有的程序响应UTF8_STRING请求时返回的是合法UTF-8但也有些老程序只认ISO-8859-1甚至什么都不认直接返回None。为此我做了一层编码兜底如果UTF8_STRING请求失败再退回请求XA_STRING拿到后用iconv转成UTF-8。虽然绝大多数现代应用用不上这个回退路径但加上之后兼容性好了不少。文本清洗同样重要。浏览器或终端里选中一段文字时可能包含首尾空格、换行、多个连续空格直接把这种原文丢给翻译API会导致翻译质量下降。我用一个简单的只遍历一次的clean_text函数把连续空白字符压缩成单个空格去除首尾空白同时限制最长输入不超过512字节既省流量也避免误把整屏文字全选进去。4. 翻译引擎接入请求、解析与缓存三个关键点4.1 在线API还是本地词典屏幕取词工具最忌讳的是查词慢。如果每次选中都实时请求外部API网络波动直接决定工具好不好用。我的方案是做两层本地缓存优先缓存未命中再走在线API。缓存采用一个简单哈希表键是原始单词值是从API解析出来的译文进程运行期间一直保留重启后清空。后来我还加了一个可选的纯文本词库文件把最常见的几千个单词预先加载进缓存离线时也能覆盖大量高频词。在线API的选择上最省事的做法是直接用有道、百度这类开放翻译接口。申请对应的密钥后构造一个URL或者POST表单解析返回的JSON即可。不同接口对免费额度和签名要求不同我在代码里封装了一个统一的translate_request接口后续想换引擎只需要改这一个文件。4.2 libcurl请求封装网络请求我选libcurl这个库在Linux下是标配API稳定且自带连接复用、超时控制比自己裸写socket加HTTP头要可靠得多。封装一个简单的函数把单词作为参数传进去返回原始的响应字符串。typedef struct { char *data; size_t size; } ResponseBuffer; static size_t write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) { ResponseBuffer *buf (ResponseBuffer *)userdata; size_t len size * nmemb; buf-data realloc(buf-data, buf-size len 1); if (!buf-data) return 0; memcpy(buf-data buf-size, ptr, len); buf-size len; buf-data[buf-size] \0; return len; } char *http_get(const char *url) { CURL *curl curl_easy_init(); ResponseBuffer buf {0}; curl_easy_setopt(curl, CURLOPT_URL, url); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); curl_easy_setopt(curl, CURLOPT_WRITEDATA, buf); curl_easy_perform(curl); curl_easy_cleanup(curl); return buf.data; }注意curl在程序启动时要做一次curl_global_init否则高并发解析域名时可能出现偶发崩溃程序退出前再做curl_global_cleanup收尾。这个细节说大不大但忘了就等着联调时排查莫名其妙的段错误。4.3 JSON解析与本地缓存翻译API返回的JSON结构比如有道的jsonapi格式基本长这样{ec:{word:{trs:[{tr:{l:{i:[good],t:[好的]}}}]}}}。我的习惯是数据格式固定且不会频繁变化时用cJSON解析这样逻辑最清晰但不引入额外大型复杂框架保留一个很小的parse_translation函数专门提取译文。char *parse_translation(const char *json) { cJSON *root cJSON_Parse(json); if (!root) return NULL; cJSON *ec cJSON_GetObjectItem(root, ec); if (!ec) { cJSON_Delete(root); return NULL; } cJSON *word cJSON_GetObjectItem(ec, word); cJSON *trs word ? cJSON_GetObjectItem(word, trs) : NULL; if (!cJSON_IsArray(trs)) { cJSON_Delete(root); return NULL; } cJSON *first cJSON_GetArrayItem(trs, 0); cJSON *tr first ? cJSON_GetObjectItem(first, tr) : NULL; cJSON *l tr ? cJSON_GetObjectItem(tr, l) : NULL; cJSON *t l ? cJSON_GetObjectItem(l, t) : NULL; char *result t cJSON_IsArray(t) cJSON_GetArrayItem(t, 0) ? strdup(cJSON_GetArrayItem(t, 0)-valuestring) : NULL; cJSON_Delete(root); return result; }本地缓存我直接用固定大小的开放寻址哈希表冲突时线性探测。键用单词本身值为翻译结果字符串。查询和写入都是O(1)足够应付日常使用。至于更复杂的持久化存储比如SQLite对一个常驻查词工具来说过度设计除非你想积累长期词库做复习功能否则内存缓存完全够。5. 悬浮提示窗无焦点GTK窗口的实现细节5.1 为什么用GTK_WINDOW_POPUP悬浮框最忌讳的是抢焦点。如果弹出一个普通GtkWindow用户正在终端里敲命令时它突然获得焦点会直接把打字焦点带走这体验是灾难性的。GTK提供了GTK_WINDOW_POPUP这一窗口类型天生没有边框、没有标题栏、不会抢焦点适合做气泡提示、补全列表这类被动信息展示正好匹配取词翻译场景。创建窗口时还需要显式调用gtk_window_set_accept_focus传入FALSE确保用户点击弹窗时它也不会主动请求输入焦点。配合gtk_window_set_keep_above让窗口始终置顶不会被其他窗口盖住。如果桌面环境支持也可以设置skip_taskbar_hint让弹窗不出现在任务栏上。static GtkWidget *popup NULL; static GtkWidget *label NULL; void popup_init(void) { popup gtk_window_new(GTK_WINDOW_POPUP); gtk_window_set_accept_focus(GTK_WINDOW(popup), FALSE); gtk_window_set_keep_above(GTK_WINDOW(popup), TRUE); gtk_window_set_skip_taskbar_hint(GTK_WINDOW(popup), TRUE); gtk_window_set_decorated(GTK_WINDOW(popup), FALSE); label gtk_label_new(NULL); gtk_label_set_line_wrap(GTK_LABEL(label), TRUE); gtk_label_set_max_width_chars(GTK_LABEL(label), 36); gtk_container_add(GTK_CONTAINER(popup), label); gtk_widget_show_all(popup); gtk_widget_hide(popup); }5.2 窗口跟随鼠标并自动消失显示翻译结果时不能再打开默认居中或者重新定位到某个固定坐标而应该跟随鼠标当前位置。取到鼠标坐标后在鼠标右下角偏移16像素处显示窗口。如果空间不足也就是鼠标在屏幕右下角时把窗口翻转到鼠标左上方显示避免弹出窗口跑出屏幕边界而看不到。自动消失逻辑我也做了两档普通翻译结果3秒后隐藏如果翻译失败或响应超时则1秒后隐藏。隐藏用的是g_timeout_add加一个标志位而不是粗暴地在GTK主线程里sleep后者会再次把界面卡住。每次显示新结果时取消上一个定时器防止闪烁。static guint hide_timeout_id 0; gboolean popup_hide_cb(gpointer data) { gtk_widget_hide(popup); hide_timeout_id 0; return G_SOURCE_REMOVE; } void popup_show(const char *text) { if (hide_timeout_id) g_source_remove(hide_timeout_id); GdkScreen *screen gdk_screen_get_default(); GdkSeat *seat gdk_display_get_default_seat(gdk_screen_get_display(screen)); GdkDevice *mouse gdk_seat_get_pointer(seat); GdkMonitor *monitor gdk_display_get_monitor_at_device( gdk_screen_get_display(screen), mouse); GdkRectangle geo; gdk_monitor_get_geometry(monitor, geo); gdk_device_get_position(mouse, NULL, gx, gy); int win_w, win_h; gtk_window_get_size(GTK_WINDOW(popup), win_w, win_h); int x gx 16; int y gy 16; if (x win_w geo.x geo.width) x gx - win_w - 16; if (y win_h geo.y geo.height) y gy - win_h - 16; gtk_label_set_text(GTK_LABEL(label), text); gtk_window_move(GTK_WINDOW(popup), x, y); gtk_widget_show(popup); hide_timeout_id g_timeout_add(3000, popup_hide_cb, NULL); }5.3 取词线程与GTK主循环的对接取词线程和翻译请求都不能直接操作GTK控件GTK并非线程安全的。正确的姿势是从子线程发送一个回调到主线程用g_idle_add最方便GTK会在主循环空闲时执行回调。这样既保证了界面响应又避免了复杂加锁。typedef struct { char *word; char *translation; } TranslationResult; gboolean show_translation_cb(gpointer data) { TranslationResult *tr (TranslationResult *)data; popup_show(tr-translation); free(tr-word); free(tr-translation); free(tr); return G_SOURCE_REMOVE; } /* 在子线程中调用把结果抛回主线程 */ void deliver_result(const char *word, const char *translation) { TranslationResult *tr malloc(sizeof(*tr)); tr-word strdup(word); tr-translation strdup(translation); g_idle_add(show_translation_cb, tr); }6. 踩坑实录从取不到词到乱码的完整排障链路6.1 第一次联调鼠标一点就丢词第一版代码很快就跑通了但实际体验有个让人崩溃的现象单独选中单词时弹窗正常可只要我为了点选弹窗里的某个按钮或者用户习惯性地单击了一下取词就失效了。查了很久才发现这是X11选区的所有权机制在起作用——当用户在其他地方单击鼠标左键时那个位置控件可能变成了新的PRIMARY所有者而旧owner上的选中内容就被释放了。也就是说屏幕取词必须区分用户正在主动取词和用户单纯点击了一下鼠标否则会反复触发无效的取词流程弹窗一闪一闪。解决思路是在取词循环里加了防抖检测到owner变化后不立即取词而是等两次轮询周期内owner保持稳定后再发起内容转换。这招很有效连续点选、拖动文字时弹窗不再乱闪。移动鼠标选中文字的过程中owner会频繁变化防抖窗口吸收掉了这些不稳定状态。6.2 中文变成一串问号第一次用中文测试取回来的原文和译文都变成了问号很长一段时间我以为是翻译API的响应编码不对。后来打印原始响应才发现问题出在取词端的编码target上代初始代码请求的是XA_STRING对应Latin-1编码遇到中文直接无解。改用UTF8_STRING后从浏览器和终端取回来的中文就正常了。不过还有一类特殊的坑部分老终端程序虽然声明支持UTF8_STRING但内部用的是当前locale编码如果locale是POSIX或C.UTF-8取回来的字符串可能是非法的UTF-8字节序列。我这边的兜底方案是对取回字符串做一次UTF-8合法性校验非法时用iconv从当前locale的编码转成UTF-8。虽然实际触发概率不高但考虑到兼容性这层保护值得保留。6.3 弹窗出现一次之后第二次怎么都不刷新这是最典型的多线程UI问题。第一版取词线程直接调用gtk_label_set_text结果弹窗只能显示一条结果之后就再也没办法更新文本。原因前面提到过GTK的控件操作必须在主线程执行。在子线程直接设置控件内容本质上是数据竞争行为完全未知。定位到问题后我把所有界面更新都收拢到g_idle_add回调里子线程只负责把结果塞到结构体里传给主线程。同时也消除了一个隐藏隐患就是翻译请求阻塞子线程导致取词循环停摆。现在翻译请求异步化之后即使某个API超时5秒取词循环也不会卡住。6.4 弹窗尺寸忽大忽小文字挤成一团GTK的窗口默认会跟着内容自动调整尺寸翻译结果长度差异很大时弹窗宽度就会忽宽忽窄。解决方案是固定最大宽度强制label换行并给窗口设置一个合理的最小宽度。这样翻译一个单词和翻译整句话时弹窗宽度都不会超出屏幕或者宽到滑稽。这个细节在中文翻译场景特别重要英文译文一行能放下中文译文动不动就十几个字不限制宽度的话直接把弹出窗口撑满半个屏幕。7. 编译部署与后续还能怎么扩展整个项目没有复杂的构建流程依赖只有三个X11开发库、libcurl、GTK3开发库。在Debian系发行版上安装依赖后用下面的Makefile就能编译。sudo apt install libx11-dev libcurl4-openssl-dev libgtk-3-devCC gcc CFLAGS -Wall -O2 -pthread pkg-config --cflags gtk-3.0 LIBS pkg-config --libs gtk-3.0 -lX11 -lcurl -lpthread SRCS main.c selection.c translator.c popup.c OBJS $(SRCS:.c.o) TARGET netdict $(TARGET): $(OBJS) $(CC) -o $ $(OBJS) $(LIBS) %.o: %.c $(CC) $(CFLAGS) -c $ -o $ clean: rm -f $(OBJS) $(TARGET) install: cp $(TARGET) /usr/local/bin/ .PHONY: clean install如果用的是Wayland会话这套方案需要配合XWayland使用因为GTK窗口与X11选区机制在纯Wayland下行为不同这也是我目前还没完全解决的领域。日常主力环境还在用X11的读者这个工具可以无缝使用。后续扩展方向也不少。最实用的是给取词线程加一个热键开关避免在写代码时鼠标不小心选中变量名就弹出翻译框干扰思路。其次是把缓存持久化到磁盘积累成个人生词表。OCR取词也可以加把屏幕某个区域截图后丢给Tesseract识别适合处理图片里的英文但这属于另一个量级的活代码结构上只要在取词模块前加一层图像识别接口就行不污染现有逻辑。我自己的使用体会是这类工具改到能解决自己痛点的程度就够了不必追求功能齐全。真正有意义的收获反而是把X11选区机制的细节、GTK多线程调用的约束、网络请求与缓存的设计完整过了一遍这套思路换到任何平台的划词工具上都适用。本文还有配套的精品资源点击获取