同步: 最近刚来渠道, 方案将自己之前在其他地方发的文章搬运 (同步) 过来.

首发日期2024-02-17, 以下为原文内容.


ibus 输入法结构自带一套用户界面, 比如 (ibus-libpinyin):

(同步) 运用 electronjs 完成 ibus 输入法的用户界面

可是从灵敏程度和可扩展的视点考虑, 最好仍是其他想办法完成用户界面, 而不是用 ibus 自带的这个.

在桌面 (PC) 渠道制造图形用户界面, 有很多很多种具体的技能可供挑选. 可是窝觉得, 其中最简略的办法便是运用 electronjs.

本文介绍运用 electronjs 完成 ibus 输入法的用户界面的办法. (留意: 并非完好完成)


相关文章:

目录

  • 1 electronjs 简介

  • 2 完成通明窗口

    • 2.1 无结构窗口

    • 2.2 通明窗口

    • 2.3 不行取得焦点

  • 3 完成光标跟从

    • 3.1 光标方位的获取

    • 3.2 完成页面接口

    • 3.3 窗口的显现和躲藏

  • 4 测验

  • 5 总结与展望

  • 附录 1 electronjs 相关代码

1 electronjs 简介

electronjs = chromium + node.js

chromium 是一个浏览器内核, 能够显现网页. node.js 是一个用 JavaScript 开发服务器 (无图形界面) 运用的运转环境. chromium 运用 v8 引擎 (js 虚拟机), node.js 也用 v8 引擎来执行 js 代码. node.js 运用 js 编程言语, 网页也用 js 言语开发.

总之, electronjs 便是 chromium 和 node.js 合体后的产物. 运用 electronjs 能够开发桌面 (PC) 运用, 支撑 3 个渠道: GNU/Linux, Windows, 苹果 mac. 其中整个运用 (非界面部分) 运用 node.js 的功用, 图形用户界面运用 chromium 显现.

(同步) 运用 electronjs 完成 ibus 输入法的用户界面

比如文本编辑器 vscode 就基于 electronjs 结构开发.

运用 electronjs 开发运用, 就和开发网页差不多, 首要运用 HTML, CSS, JavaScript 等 web 技能. 也能够运用 vue 等结构.

从非开发者的视点, web 技能的首要长处是, 开发速度快, 低本钱. 从开发者的视点, 完成相同的功用 (作用), 大部分情况下, 运用 web 技能, 比较其他图形界面技能, 都要简略简单很多.

web 技能是现在最好 (乃至仅有真实) 的跨渠道技能. 跨渠道便是一套代码, 能够在多个渠道运转, 比如不同的操作系统 (GNU/Linux, Android, Windows), 不同的设备形态 (PC, 手机) 等. 假如不运用跨渠道技能, 同一个运用就要别离开发屡次, 本钱瞬间就增加了很多倍. 究竟大部分情况下, 低本钱才是王道 (看窝网名).


一点八卦:

关于 electron 这个命名. 当年 github 做了一个文本编辑器, 叫 Atom (原子). Atom 运用的结构开始叫 atom-shell (原子外壳), 后来改名叫 electron (电子). 后来微软开宣布 vscode, 也基于 electron, 作业原理和 Atom 差不多, 算是直接竞争对手. 再后来, 微软收买了 github, 于是 Atom 死了, 只留下了 electronjs.

2 完成通明窗口

根据咱们日常的运用习惯, 拼音输入法首要有两个界面: 固定工具条候选框.

(同步) 运用 electronjs 完成 ibus 输入法的用户界面

其中固定工具条一般放在屏幕边上, 能够切换一些输入状况, 比如中英文, 全角半角之类的. 候选框跟从光标移动, 里边显现多个候选项.

这些窗口和通常的窗口不同, 比如没有标题栏和关闭按钮. 本章节来完成这样的窗口.

参阅文档:

2.1 无结构窗口

常见的普通窗口是有结构 (frame) 的, 比如标题栏, 关闭按钮等.

创立无结构窗口运用如下代码:

new BrowserWindow({
  //
  frame: false,
})

没有了标题栏之后, 窗口就无法移动了. 可是能够创立可拖动的区域, 比如:

img {
  -webkit-app-region: drag;
}

在页面中写这样的 CSS 代码, 然后猫猫头就能够拖动了:

(同步) 运用 electronjs 完成 ibus 输入法的用户界面

2.2 通明窗口

(同步) 运用 electronjs 完成 ibus 输入法的用户界面

仔细观察这个窗口, 是有圆角作用的. 圆角的四个角那里, 仍然是归于窗口的区域, 可是具有通明作用.

new BrowserWindow({
  // 通明窗口
  transparent: true,
})

首先需求在 electronjs 设置相应的选项.

new BrowserWindow({
  //backgroundColor: "#FFF3E0",

留意此处不能设置 backgroundColor.

body {
  background-color: transparent;
}

最后在页面中增加 CSS.

2.3 不行取得焦点

当前正在输入内容的窗口, 也便是有文本光标的窗口, 是取得焦点 (focus) 的窗口. 同一时间只能有一个窗口取得焦点, 所以候选框这个特别的窗口, 不能取得焦点.

想象一下, 假如候选框也能取得焦点, 那么: (1) 在窗口 1 输入拼音; (2) 弹出候选框窗口; (3) 候选框取得焦点; (4) 窗口 1 失掉焦点; (5) 输入撤销.

具体表现大约是: 每次输入拼音, 候选框总是闪一下, 然后消失 .. . 古怪的 BUG !

// im1: 候选框
窗口.im1 = new BrowserWindow({
  width: 800,
  height: 200,
  x: 100,
  y: 300,
  //backgroundColor: "#FFF3E0",
  autoHideMenuBar: true,
  // 不行调整巨细
  resizable: false,
  // 置顶窗口
  alwaysOnTop: true,
  // 无边框
  frame: false,
  // 通明窗口
  transparent: true,
  // 默认躲藏窗口
  show: false,
  // 不行取得焦点
  focusable: false,
  webPreferences: {
    preload,
  },
});

总结以上种种, 运用这样的代码创立候选框窗口.

3 完成光标跟从

候选框窗口需求跟从屏幕上文本光标的方位, 也便是显现在光标周围. 假如没有光标跟从, 候选框固定在屏幕的一个方位, 用起来就会很难过.

3.1 光标方位的获取

  • 源文件: ibus/src/ibusengine.c (详见 《ibus 源代码阅览 (1)》)
<node>
  <interface name='org.freedesktop.IBus.Engine'>
    <method name='SetCursorLocation'>
      <arg direction='in'  type='i' name='x' />
      <arg direction='in'  type='i' name='y' />
      <arg direction='in'  type='i' name='w' />
      <arg direction='in'  type='i' name='h' />
    </method>

ibus 输入法结构 (ibus-daemon) 会经过 D-Bus 接口 org.freedesktop.IBus.Engine 办法 SetCursorLocation 给输入法发送屏幕上光标的方位. 4 个参数别离为光标的 x, y 方位和宽高. 留意这儿的 x, y 是相对整个屏幕的坐标.

参阅文档: ibus.github.io/docs/ibus-1…

输入法这边的完成代码 (rust):

#[interface(name = "org.freedesktop.IBus.Engine")]
impl<T: IBusEngine + 'static> Engine<T> {
    async fn set_cursor_location(
        &mut self,
        #[zbus(signal_context)] sc: SignalContext<'_>,
        x: i32,
        y: i32,
        w: i32,
        h: i32,
    ) -> fdo::Result<()> {
        self.e.set_cursor_location(sc, x, y, w, h).await
    }

留意: 此处的代码升级到了 zbus 4.0 docs.rs/zbus/4.0.1/…

文章 《ibus 源代码阅览 (1)》 写的时候对应 zbus 3.15 版本. 具体接口有一点变化.

3.2 完成页面接口

要完成光标跟从, 就需求移动候选框窗口的方位. 这是 electronjs 的功用. 假如光标跟从的功用在页面上完成, 页面上的代码就需求调用 electronjs. 需求完成相应的页面接口.

const preload = path.join(__dirname, "preload.js");
new BrowserWindow({
  webPreferences: {
    preload,
  },
});

创立窗口的时候, 需求加载一个 js 文件, 作为桥接:

// pmim-ibus electronjs preload.js
const { contextBridge, ipcRenderer } = require("electron");
// electronjs 接口桥接
contextBridge.exposeInMainWorld("pmim_ea", {
  electron_version: () => ipcRenderer.invoke("ea:electron_version"),
  read_token: () => ipcRenderer.invoke("ea:read_token"),
  窗口显现0: () => ipcRenderer.invoke("ea:窗口显现0"),
  窗口躲藏0: () => ipcRenderer.invoke("ea:窗口躲藏0"),
  窗口显现: () => ipcRenderer.invoke("ea:窗口显现"),
  窗口躲藏: () => ipcRenderer.invoke("ea:窗口躲藏"),
  窗口长宽: (w, h) => ipcRenderer.invoke("ea:窗口长宽", w, h),
  窗口方位: (x, y) => ipcRenderer.invoke("ea:窗口方位", x, y),
});

桥接文件的代码类似这样.

const { app, BrowserWindow, ipcMain } = require("electron");
// 省掉
async function 窗口方位(_, x, y) {
  if (null != 窗口.im1) {
    窗口.im1.setPosition(x, y);
  }
}
ipcMain.handle("ea:electron_version", electron_version);
ipcMain.handle("ea:read_token", read_token);
ipcMain.handle("ea:窗口显现0", 窗口显现0);
ipcMain.handle("ea:窗口躲藏0", 窗口躲藏0);
ipcMain.handle("ea:窗口显现", 窗口显现);
ipcMain.handle("ea:窗口躲藏", 窗口躲藏);
ipcMain.handle("ea:窗口长宽", 窗口长宽);
ipcMain.handle("ea:窗口方位", 窗口方位);

在 electronjs 主文件里边完成对应的接口. 然后, 页面上的代码就能够这样调用了:

await window.pmim_ea.窗口方位(x, y);

3.3 窗口的显现和躲藏

function 处理状况() {
  // 状况追踪
  return {
    // 窗口显现状况
    应该显现: false,
    实践显现: false,
    // 光标方位
    光标: [0, 0, 0, 0],
  };
}
// 省掉
  // 处理音讯 (更新状况)
  if ("s" == 音讯.类型) {
    switch (音讯.文本) {
      case "focus_in":
        状况.应该显现 = true;
        break;
      case "focus_out":
        状况.应该显现 = false;
        break;
      case "disable":
        状况.应该显现 = false;
        //await 窗口躲藏0();
        break;
      case "enable":
        //await 窗口显现0();
        break;
    }
  } else if ("c" == 音讯.类型) {
    // 追踪光标方位
    if ((0 != 音讯.x) || (0 != 音讯.y)) {
      状况.光标 = [音讯.x, 音讯.y, 音讯.w, 音讯.h];
    }
  } else if ("k" == 音讯.类型) {
    // 按键处理
    // TODO
  } else if ("t" == 音讯.类型) {
    // 更新原始输入
    输入.value = 音讯.文本;
  }
  // 处理窗口移动
  if ((0 != 状况.光标[0]) || (0 != 状况.光标[1])) {
    // TODO 优化方位挑选
    const x = 状况.光标[0];
    const y = 状况.光标[1] + 状况.光标[3] + 16;
    await 窗口方位(x, y);
  }
  // 处理窗口显现/躲藏
  if (状况.实践显现) {
    if (!状况.应该显现) {
      await 窗口躲藏();
      状况.实践显现 = false;
      return;
    }
    if (输入.value.length < 1) {
      await 窗口躲藏();
      状况.实践显现 = false;
      return;
    }
  } else if (状况.应该显现) {
    if (
      ((0 != 状况.光标[0]) || (0 != 状况.光标[1])) && (输入.value.length > 0)
    ) {
      await 窗口显现();
      状况.实践显现 = true;
      return;
    }
  }

这是一段页面上的代码, 随意写了写, 还没来得及整理和优化. 随意看看就好了.

这段代码开始完成了光标跟从的功用.

4 测验

又到了激动人心的测验环节.

  • (1) 发动 vue 项目 (开发形式):

    > npm run dev
    

    页面运用 vue 3.4 (vite) 结构开发. 这又是另一个故事了 .. .

    vuejs.org/

  • (2) 运转 electronjs (完好代码详见 附录 1):

    > electron main.js
    

光标跟从功用的测验截图如下:

(同步) 运用 electronjs 完成 ibus 输入法的用户界面

(同步) 运用 electronjs 完成 ibus 输入法的用户界面

(同步) 运用 electronjs 完成 ibus 输入法的用户界面

5 总结与展望

能够看到, 运用 electronjs 完成图形界面仍是很简略方便的.

比较 ibus 结构自带的用户界面, 灵敏程度和可扩展能力都得到了大幅度进步.

在 web 技能的加持之下, 制造输入法就能够放飞自我了. 应该能够简单的做出丰富多样的用户界面.

附录 1 electronjs 相关代码

  • main.js
// pmim-ibus electronjs
const path = require("node:path");
const { readFile } = require("node:fs/promises");
const { app, BrowserWindow, ipcMain } = require("electron");
const LOGP = "pmim-ibus electronjs";
function logi(t) {
  console.log(LOGP + t);
}
// DEBUG
logi(": main.js");
const 开发地址 = "http://localhost:5173"; // vue `npm run dev`
function 获取加载地址() {
  const 端口 = process.env["PMIM_PORT"];
  if (端口 != null) {
    return `http://127.0.0.1:${端口}`;
  }
  return 开发地址;
}
// 保存创立的窗口
const 窗口 = {
  // 主窗口
  主: null,
  // im0: 常驻工具条
  im0: null,
  // im1: 候选框
  im1: null,
};
function 初始化接口() {
  // 获取 electronjs 版本信息
  async function electron_version() {
    return process.versions;
  }
  // 读取 deno/fresh server http token
  async function read_token() {
    const xrd = process.env["XDG_RUNTIME_DIR"];
    const 口令文件 = path.join(xrd, "pmim/server_token");
    logi(" read token: " + 口令文件);
    return await readFile(口令文件, { encoding: "utf8" });
  }
  async function 窗口显现0() {
    if (null != 窗口.im0) {
      窗口.im0.showInactive();
    }
  }
  async function 窗口躲藏0() {
    if (null != 窗口.im0) {
      窗口.im0.hide();
    }
  }
  async function 窗口显现() {
    if (null != 窗口.im1) {
      窗口.im1.showInactive();
    }
  }
  async function 窗口躲藏() {
    if (null != 窗口.im1) {
      窗口.im1.hide();
    }
  }
  async function 窗口长宽(_, w, h) {
    if (null != 窗口.im1) {
      窗口.im1.setSize(w, h);
    }
  }
  async function 窗口方位(_, x, y) {
    if (null != 窗口.im1) {
      窗口.im1.setPosition(x, y);
    }
  }
  ipcMain.handle("ea:electron_version", electron_version);
  ipcMain.handle("ea:read_token", read_token);
  ipcMain.handle("ea:窗口显现0", 窗口显现0);
  ipcMain.handle("ea:窗口躲藏0", 窗口躲藏0);
  ipcMain.handle("ea:窗口显现", 窗口显现);
  ipcMain.handle("ea:窗口躲藏", 窗口躲藏);
  ipcMain.handle("ea:窗口长宽", 窗口长宽);
  ipcMain.handle("ea:窗口方位", 窗口方位);
}
function 创立窗口() {
  const preload = path.join(__dirname, "preload.js");
  // 主窗口
  窗口.主 = new BrowserWindow({
    width: 400,
    height: 700,
    backgroundColor: "#FFF3E0",
    autoHideMenuBar: true,
    show: false,
    webPreferences: {
      preload,
    },
  });
  // im0: 常驻工具条
  窗口.im0 = new BrowserWindow({
    width: 400,
    height: 100,
    x: 100,
    y: 100,
    //backgroundColor: "#FFF3E0",
    autoHideMenuBar: true,
    // 不行调整巨细
    resizable: false,
    // 置顶窗口
    alwaysOnTop: true,
    // 无边框
    frame: false,
    // 通明窗口
    transparent: true,
    // 默认躲藏窗口
    //show: false,
    webPreferences: {
      preload,
    },
  });
  // im1: 候选框
  窗口.im1 = new BrowserWindow({
    width: 800,
    height: 200,
    x: 100,
    y: 300,
    //backgroundColor: "#FFF3E0",
    autoHideMenuBar: true,
    // 不行调整巨细
    resizable: false,
    // 置顶窗口
    alwaysOnTop: true,
    // 无边框
    frame: false,
    // 通明窗口
    transparent: true,
    // 默认躲藏窗口
    show: false,
    // 不行取得焦点
    focusable: false,
    webPreferences: {
      preload,
    },
  });
  // DEBUG
  //窗口.im1.webContents.openDevTools();
  // TODO 延迟加载页面
  const url = 获取加载地址();
  const u1 = url + "/index.html";
  logi(" URL: " + u1);
  窗口.主.loadURL(u1);
  const u2 = url + "/im0/index.html";
  logi(" URL: " + u2);
  窗口.im0.loadURL(u2);
  const u3 = url + "/im1/index.html";
  logi(" URL: " + u3);
  窗口.im1.loadURL(u3);
}
app.whenReady().then(() => {
  初始化接口();
  创立窗口();
});
// TODO
app.on("window-all-closed", () => {
  app.quit();
});
  • preload.js
// pmim-ibus electronjs preload.js
const { contextBridge, ipcRenderer } = require("electron");
// electronjs 接口桥接
contextBridge.exposeInMainWorld("pmim_ea", {
  electron_version: () => ipcRenderer.invoke("ea:electron_version"),
  read_token: () => ipcRenderer.invoke("ea:read_token"),
  窗口显现0: () => ipcRenderer.invoke("ea:窗口显现0"),
  窗口躲藏0: () => ipcRenderer.invoke("ea:窗口躲藏0"),
  窗口显现: () => ipcRenderer.invoke("ea:窗口显现"),
  窗口躲藏: () => ipcRenderer.invoke("ea:窗口躲藏"),
  窗口长宽: (w, h) => ipcRenderer.invoke("ea:窗口长宽", w, h),
  窗口方位: (x, y) => ipcRenderer.invoke("ea:窗口方位", x, y),
});

本文运用 CC-BY-SA 4.0 答应发布.