主题与国际化

主题(Theme)与国际化(i18n)是两个看似不同的话题,在 rust-agent-flow 里却共享同一套机制:集中定义、随编辑器持有、通过 `NodeViewCtx`/`ToolbarCtx` 传递给渲染层。它们不是传统意义的「扩展点 trait」,但都遵循「能力在框架、呈现归调用方」的精神——框架管配色与文案表,调用方决定何时切换、怎么触发。

Theme:集中配色

`Theme` 是一个纯数据结构,集中管理编辑器所有颜色:

#[derive(Clone, Copy, Debug)]
pub struct Theme {
    pub is_dark: bool,
    // 画布
    pub canvas_bg: Rgba,
    pub grid_dot: Rgba,
    // 边
    pub edge_default: Rgba,
    pub edge_loop_back: Rgba,
    // 端口
    pub port_bg: Rgba,
    // 节点(所有节点统一)
    pub node_bg: Rgba, pub node_title_bg: Rgba,
    pub node_border: Rgba, pub node_border_selected: Rgba,
    pub node_text: Rgba, pub node_title_text: Rgba, pub node_subtext: Rgba,
    pub node_in_dot: Rgba, pub node_in_ring: Rgba,
    pub node_out_dot: Rgba, pub node_out_ring: Rgba,
    // 属性面板
    pub panel_bg: Rgba, pub panel_border: Rgba,
    pub panel_title_text: Rgba, pub panel_label_text: Rgba, pub panel_subtext: Rgba,
    // 节点按钮 / 工具栏 / 边「+」按钮 ...
    pub delete_btn_bg: Rgba, pub toggle_btn_bg: Rgba, pub collapse_pill_bg: Rgba,
    pub toolbar_bg: Rgba, pub toolbar_border: Rgba, pub toolbar_accent: Rgba, ...
    pub edge_plus_bg: Rgba, pub edge_plus_hover_bg: Rgba,
}

两个要点:

- **所有节点统一颜色**:不为 Start/End/Condition/Loop 定义特殊色,节点类型靠图标和文字区分。这避免了「每加一种节点就要配一套色」的负担。 - **Copy + 按值传递**:`Theme` 是 `Copy`,渲染时直接拷贝进闭包,无需 `Arc`。`NodeViewCtx`/`ToolbarCtx` 持有它的副本。

亮暗预设与切换

`Theme::light()` / `Theme::dark()` 提供两套预设,`toggle()` 互换:

impl Theme {
    pub fn light() -> Self { /* 浅灰底 + indigo 强调 */ }
    pub fn dark() -> Self  { /* 深蓝灰底 + 浅紫强调 */ }
    pub fn toggle(self) -> Self {
        if self.is_dark { Self::light() } else { Self::dark() }
    }
}

强调色(节点边框选中、入端口、按钮 accent)在亮色用 `#6366f1`(indigo),暗色用 `#818cf8`(浅紫),保证两种背景下都有足够对比度。

切换主题:三重同步

`toggle_theme` 不只是改自己的 `theme` 字段,还要同步三处:

pub fn toggle_theme(&mut self, cx: &mut Context<Self>) {
    self.theme = self.theme.toggle();
    // 1. 同步 gpui-component 全局主题(Button/DropdownMenu 等组件图标文字跟随)
    let mode = if self.theme.is_dark { ThemeMode::Dark } else { ThemeMode::Light };
    gpui_component::Theme::change(mode, None, cx);
    // 2. 显式通知已存在的 panel_view(它持有 theme 快照)
    if let Some(panel) = &self.panel_view {
        panel.set_theme(self.theme, cx);
    }
    // 3. 刷新窗口 + 通知重绘
    cx.refresh_windows();
    cx.notify();
}

为什么要三重同步?

| 同步目标 | 原因 | |----------|------| | `self.theme` | 画布、节点、边、工具栏渲染都读它 | | `gpui_component::Theme` | 框架用的 Button/DropdownMenu 等组件读全局主题,不同步会暗底白字不可见 | | `panel_view.set_theme` | 面板持有 theme 副本,不通知就用旧色重绘 |

注意 panel_view 这里**不销毁重建**——theme 是纯数据,`set_theme` 直接覆盖字段 + `cx.notify()` 即可。对比 syntax/data-type/language 的销毁重建:那些扩展的产物(InputState 的 code_editor 配置、DataTypeRegistry、i18n 文案)在构造时固化进组件,只能重建;而 theme 只是渲染时读取的参数,热替换无副作用。

i18n:Language 与 TKey

国际化由 `Language` 枚举 + `TKey` 枚举 + `t()` 函数构成:

#[derive(Clone, Copy, Default, PartialEq, Eq)]
pub enum Language { #[default] Zh, En }

impl Language {
    pub fn toggle(self) -> Self { match self { Zh => En, En => Zh } }
    pub fn is_zh(self) -> bool { matches!(self, Zh) }
}

pub fn t(lang: Language, key: TKey) -> &'static str {
    match lang { Language::Zh => t_zh(key), Language::En => t_en(key) }
}

`TKey` 枚举穷举了框架所有需国际化的文字:

| 分组 | 示例 TKey | |------|-----------| | 节点类型标签 | Start/End/Action/Condition/Loop/Variable/DataAdapter/Agent | | Condition | If/Else | | Loop 模式 | LoopForEach/LoopForLoop/LoopWhile/LoopParallel | | 面板 | PanelTitle/PanelKind/PanelNodeName/PanelConditions/PanelAddBranch/PanelLoopMode... | | Start/End 面板 | PanelParams/PanelVariables/PanelReturns/PanelAddParam... | | 工具栏 tooltip | TbZoomIn/TbFitView/TbToggleDrag/TbToggleTheme/TbToggleLanguage/TbDataSource | | 数据源名称 | DataSourceAgentFlow/DataSourceDataPipeline/DataSourceSimpleFlow | | 内置数据类型 | TypeString/TypeInteger/TypeFloat/TypeBoolean/TypeDateTime/TypeDynamic |

关键边界:节点 label 不受 i18n 影响

这是一条容易混淆的规则:

| 文字类型 | 来源 | 是否随语言切换 | |----------|------|----------------| | 节点 label(如 "规划") | `node.data["label"]` | 否,业务数据 | | 节点类型标签(如 "动作") | `t(lang, TKey::Action)` | 是 | | 字段标签(如 "条件分支") | `field_label` 映射 `t(lang, TKey::PanelConditions)` | 是 | | 按钮 tooltip | `t(lang, TKey::TbXxx)` | 是 |

节点 label 是用户起的业务名字,存 JSON,换语言不该改它;类型标签/字段标签/tooltip 是框架 UI 文字,必须跟随语言。`kind_label` 和 `field_label` 负责把 kind/field_key 映射到 TKey,再 `t()` 取文案。

切换语言:销毁重建

`set_language` 是替换型注入,销毁 panel_view:

pub fn set_language(&mut self, language: Language, cx) {
    self.language = language;
    self.panel_view = None;   // 销毁现有面板
    cx.notify();
}

pub fn toggle_language(&mut self, cx) {
    self.set_language(self.language.toggle(), cx);
}

为什么语言切换要重建面板,而主题不用?因为面板构造时把 `t(lang, TKey::Xxx)` 的结果固化进了多处:字段标签、placeholder、添加按钮文字……这些都是 `String`,存在 div 里,不会随 `language` 字段变化而重渲染。销毁重建确保所有文案重新取值。

对比三类扩展的注入语义:

| 扩展 | 注入类型 | 销毁 panel_view | |------|----------|-----------------| | ToolbarProvider | 累积(push) | 否(每帧动态渲染) | | IDataTypeProvider | 替换 | 是(registry 固化进 ItemState) | | SyntaxService | 替换 | 是(code_editor 配置固化进 InputState) | | Language | 替换 | 是(文案固化进 div) | | Theme | 替换 | 否(纯数据热替换) |

辅助映射函数

i18n 模块还提供两个映射函数:

// kind → 本地化类型标签
pub fn kind_label(lang, kind: &str) -> &'static str { ... }

// 内置数据类型名 → 本地化显示名(provider 类型原样返回)
pub fn data_type_label(lang, type_name: &str) -> &str { ... }

`data_type_label` 对内置类型翻译("String"→"文本"),对 provider 的 Complex 类型原样返回类型名——因为 provider 类型名是业务定义的,框架无从翻译。若需翻译 provider 类型名,可在 provider 侧自行做本地化。

小结

`Theme` 集中管理配色,亮暗预设 + `toggle()`,切换时三重同步(self + gpui-component 全局 + panel_view 副本),纯数据热替换无需重建面板。i18n 由 `Language`/`TKey`/`t()` 构成,节点 label 不受影响;语言切换是替换型注入,因文案固化进 div 而销毁重建 panel_view。理解「哪些扩展需重建、哪些不需」的关键在于:注入物是否在构造时被消费固化。

下一章:[项目组织与集成](../16-best-practices/project-structure.md)