首页
/ opencode RTL 支持开发指南:从方向感知布局到 Electron 标题栏的完整实践

opencode RTL 支持开发指南:从方向感知布局到 Electron 标题栏的完整实践

2026-09-04 22:25:52作者:秋泉律Samson

OpenCode 的 Web 应用与桌面端需要同时服务 LTR(左到右)与 RTL(右到左)用户,仓库中的 rtl-aware-development Skill(.opencode/skills/rtl-aware-development/SKILL.md)把这一需求沉淀为一套可复用的开发准则。本文以该 Skill 为主体,逐条拆解其核心规范,并结合 packages/apppackages/desktoppackages/ui 中的真实源码说明 opencode 是如何落地“方向与语言解耦、逻辑属性优先、物理坐标显式处理”的完整 RTL 支持方案的。读完后,你可以在任意 opencode 前端模块中正确地实现或评审 RTL/LTR 行为。

核心原则:方向独立于语言

Skill 的开篇即给出总纲:Treat direction as independent from language——把书写方向视为独立于语言的选择,并且测试时必须覆盖“英文 + LTR”“英文 + 强制 RTL”以及真实 RTL 语言与混合书写的组合。

这一点直接决定了实现方式:不能靠“换一个 RTL 语言包”来验证方向行为,因为真实用户的界面语言与阅读方向是可以解耦的。opencode 源码正是这么做的——在 language.tsx 中,方向由两部分推导:

  • 语言本身的默认方向:RTL_LOCALES 集合(arurpafadv)命中则 "rtl",否则 "ltr",见 language.tsx#L23-L27
  • 用户可显式覆盖:setLayout("direction", ...) 提供方向覆盖,且当覆盖值与语言默认方向一致时会自动归一为 undefined(即回到“跟随语言”),见 language.tsx#L237-L239

因此“选择阿拉伯语”不等于“强制 RTL”,反之亦然。这也正是 Skill 所强调的“Do not change the selected locale merely to force RTL”。

在文档上设置 lang 与 dir,并传播到弹层组件

Skill 第一条准则:在 document 上设置 langdir,并把方向传播到承载 Portal 化菜单和弹层的组件 Provider 中。

opencode 的落地分两层。第一层是文档级:LanguageProvider 中有一个副作用,持续把语言与方向同步到 <html> 元素并写入 Cookie(用于 SSR/首屏),见 language.tsx#L207-L213

createEffect(() => {
  if (typeof document !== "object") return
  const value = locale()
  document.documentElement.lang = intl()
  document.documentElement.dir = direction()
  document.cookie = cookie(value)
})

第二层是组件级。Solid 的 Portal 化组件(下拉菜单、Popover)脱离文档 DOM 位置后仍需要知道方向,opencode 通过一个专门的 layoutLocale 推导值解决:

const layoutLocale = createMemo(() => {
  if (!layout.direction) return intl()
  // Kobalte derives menu direction from locale rather than accepting a direction override.
  return layout.direction === "rtl" ? "ar" : "en"
})

注释直说了原因:Kobalte 的 I18n Provider 从 locale 推导菜单方向,而不是接受方向覆盖,因此用 "ar" / "en" 这两个代理 locale 把方向语义“翻译”给 UI 库。该值经由 app.tsx#L237-L241 注入 I18nProvider

<I18nProvider
  value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
>

最终在 packages/ui/src/context/i18n.tsx#L64 中,layoutLocale 优先于 locale 生效,菜单等 Portal 组件的方向由此与主文档保持一致。

保持语义化的 DOM 与焦点顺序

Skill 指出:Flexbox 和 Grid 本身会遵循 dir不要为了镜像布局而添加 row-reverse、CSS order 或颠倒的 DOM 结构,以保持 DOM 顺序与焦点顺序的语义一致性。

这是一条约束类准则而非实现类:它的价值在于评审检查项——当某段代码出现 flex-row-reverseorder: -1 时,应优先怀疑它是在硬编码镜像 LTR 布局,而不是响应 dir。在 opencode 的组件树中(例如 titlebar-tab-strip.tsx 这类横向条带组件),布局均依赖标准 flex 方向,方向切换由 dir 属性自动完成,无需额外反向样式。

优先使用逻辑属性,物理值只留给真正物理的场景

Skill 中给出了最典型的 CSS 对照示例,这是 RTL 改造的高频操作点:

/* Avoid */
padding-left: 12px;
right: 0;
border-right: 1px solid;
text-align: left;

/* Prefer */
padding-inline-start: 12px;
inset-inline-end: 0;
border-inline-end: 1px solid;
text-align: start;

逻辑属性(padding-inline-startinset-inline-end 等)随 dir 自动翻转,一份样式同时服务两种方向。Skill 同时划定了物理坐标的合法保留场景:指针位置、Canvas 几何、原生窗口控件等确实与屏幕物理位置绑定的部分。opencode 的标题栏实现正是这个边界的范例(见下文 Electron 章节)。

隔离混合方向文本

Skill 要求:未知方向的文本用 dir="auto"<bdi> 隔离;代码、URL、ID、文件系统路径必须保持 LTR,但不要为此把外层组件整体强制为 LTR。原文档给出的示例:

<span class="file-row"><bdi dir="auto">README.md</bdi></span> <bdi dir="ltr"><code>C:\src\app.ts</code></bdi>

这在 opencode 这类编码代理产品里尤为关键:会话面板、文件树、终端面板会同时渲染用户输入的任意语言文本与代码/路径。典型风险是 RTL 段落中嵌入 C:\src\app.ts 这类路径时,Unicode 双向算法会把反斜杠位置排错;用 <bdi dir="ltr">(或 unicode-bidi: isolate)把路径作为整体隔离,即可保证路径字符序稳定,同时不影响外层 RTL 布局。

镜像“方向语义”而不是“所有图像”

Skill 明确区分了该镜像与不该镜像的元素:

  • 可以/需要镜像:返回/前进、上一页/下一页、折叠展开指示器、缩进方向、方向性进度条;
  • 不能镜像:品牌 Logo、时钟、媒体控制按钮、图表、文本本身;
  • 需要显式反向:物理方向的渐变、translateX、SVG transform、动画增量(delta)。

最后一类最容易被遗漏:translateX(20px) 在 LTR 下表示“向右移动”,但在语义上若表示“进入”,RTL 下应为负值。评审时可以把“代码里所有 translateXgradient(to right, ...)、SVG transform”作为检查清单,逐条确认它表达的是物理方向还是语义方向。

交互映射:clientX 永远是物理的

Skill 指出的核心矛盾:clientX 等指针坐标是物理的,而“逻辑边”的 resize 需要方向感知的增量换算——在 LTR 下拖动右边缘,delta = clientX - startX 直接就是宽度增量;在 RTL 下同一边缘在视觉上位于左侧,增量需要取反。此外,语义化的“上一个/下一个”键盘控件在 RTL 下可能交换 ArrowLeft/ArrowRight,具体应遵循对应的 WAI-ARIA 组件模式(如 Window splitter 模式)。

这条准则指导 resize 手柄、splitter、横向滚动条等组件的实现:事件坐标不做转换,但“把物理 delta 映射到逻辑尺寸变化”的那一层必须引入方向分支。

不要假设 LTR 的滚动行为

Skill 提示:RTL 页面的 scrollLeft 可以从 0 开始并向负值增长(滚动起点在右侧),跨方向代码不能假设 scrollLeft >= 0 或“滚到尽头时 scrollLeft === scrollWidth - clientWidth”。推荐做法是用 scrollIntoView({ inline: "nearest" }) 或经过双向验证的方向归一化辅助函数。

这一条对应 Skill 测试矩阵中的“both LTR and RTL scroll endpoints”——验证时必须检查两个方向的滚动端点行为,而不是只看 LTR。

Electron 标题栏:物理与逻辑的边界

Skill 对 Electron 标题栏的要求非常具体:优先使用原生 caption 控件,用 titleBarOverlay + env(titlebar-area-*) 定义安全内容矩形;Windows/macOS 的原生控件避让区和 trafficLightPosition 保持物理坐标;矩形内部的应用导航则使用逻辑布局;标题栏内可交互子元素标记 app-region: no-drag

opencode 桌面端的主进程实现与之完全对应,见 windows.ts#L186-L198

...(process.platform === "darwin"
  ? {
      titleBarStyle: "hidden" as const,
      trafficLightPosition: { x: 14, y: 14 },   // 物理坐标:红绿灯按钮位置
    }
  : {}),
...(process.platform === "win32"
  ? {
      frame: false,
      titleBarStyle: "hidden" as const,
      titleBarOverlay: overlay({ mode }),       // 原生 caption 控件覆盖层
    }
  : {}),
  • macOS:隐藏标题栏,trafficLightPosition{ x: 14, y: 14 } 这一物理坐标避让红绿灯按钮;
  • Windows:无边框窗口叠加原生 titleBarOverlay,把最小化/最大化/关闭交给系统绘制,从而天然获得正确方向与高 DPI 行为。

渲染进程侧则用 CSS 环境变量读取系统给出的安全矩形。titlebar.tsx#L184-L185 中:

width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,

env(titlebar-area-width, <fallback>) 优先取 Electron 注入的实际可用宽度,取不到时回退到按控件宽度估算的值——安全矩形内部的应用导航随后按逻辑属性布局,方向翻转时自动适配。而可交互的标题栏元素则显式声明不参与窗口拖拽,如 titlebar-tab-strip.tsx#L291[app-region:no-drag]

验证:看行为,而不只是截图

Skill 的收尾准则:验证行为,而非仅截图——需要检查计算样式(computed styles)、伪元素几何、命中区域、焦点顺序、键盘行为、子菜单方向、缩放/分级、以及 LTR 与 RTL 双方向的滚动端点。

Skill 给出的测试矩阵是验收清单:

组合 覆盖点
英文 + LTR 基线行为
英文 + 强制 RTL 方向与语言解耦
真实 RTL 语言 + RTL 真实脚本(含标点/数字)
混合 RTL/LTR 内容、长标签、数字、代码、路径 双向文本与 LTR 豁免内容
键盘、指针 resize、滚动、菜单/子菜单、Electron 标题栏控件,双方向 交互与物理/逻辑映射

仓库内有一个现成的验证入口:调试栏(debug-bar.tsx#L566-L573)提供一个方向切换开关,点击即在 LTR/RTL 之间翻转(调用上文提到的 language.setDirection),对应测试矩阵中“英文 + 强制 RTL”这一组合,无需切换界面语言即可全量走查上述检查项。

小结

这套 Skill 的核心可归纳为四句话:方向与语言解耦(dir 独立可覆盖,Portal 组件经 layoutLocale 感知方向);布局用逻辑属性、保持语义化 DOM;物理量(指针、控件避让、trafficLightPosition)显式保留,语义量(镜像图标、resize 增量、键盘左右键)显式映射;验证看行为矩阵而非截图。以上每条准则都能在当前仓库中找到对应实现或验证入口,可直接作为 opencode 前端 RTL 改造与 Code Review 的检查标准。

登录后查看全文
热门项目推荐
相关项目推荐