简介:基于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. 一个朴素的原型:先明确工具要满足什么需求
动手之前先把需求限定清楚。我要做的是"屏幕取词翻译",和市面上"划词翻译""剪贴板翻译"最大的区别在于:用户不需要按Ctrl+C复制,只需要用鼠标选中文字,工具就要自动感知这次选中并给出翻译结果。这个交互方式决定了它背后必须有一个常驻进程,在后台不断感知"用户选中了什么"。
需求收敛之后只有三条:
- 选中即译:鼠标选中任意单词或短句,自动弹出悬浮框显示翻译,不需要任何快捷键。
- 轻量常驻:进程常驻后台,内存占用尽量低,不挑桌面环境,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对应的是Ctrl+C复制。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多线程调用的约束、网络请求与缓存的设计完整过了一遍,这套思路换到任何平台的划词工具上都适用。
本文还有配套的精品资源,点击获取