// ---------------------------------------------------------------------------
// 主题系统:深色/浅色/自定义一键切换,令牌级主题变体管理。
//
// 核心洞察:主题 = 同一组令牌名映射到不同的值。
// Agent 只需引用令牌(`"primary"`),切换主题时所有节点自动更新。
//
// - 预定义主题:light / dark / high_contrast / sepia / nord / sunset
// - 自动暗色:从当前主题反相推导暗色版本(RGB 通道反转,色相旋转 180°)
// - 自定义主题:Agent 给一个 partial override Map → 合并到基础主题
// - 令牌级切换:`set_token("primary", "#FF5722")` → 全局生效
// ---------------------------------------------------------------------------
// 主题定义
// ---------------------------------------------------------------------------
pub(all) struct Theme {
name : String
display_name : String
tokens : DesignTokens
is_dark : Bool
}
///|
/// 预定义主题注册表
pub fn builtin_themes() -> Array[Theme] {
[
Theme::light(),
Theme::dark(),
Theme::high_contrast(),
Theme::sepia(),
Theme::nord(),
Theme::sunset(),
]
}
// ---------------------------------------------------------------------------
// 预定义主题
// ---------------------------------------------------------------------------
///|
pub fn Theme::light() -> Theme {
let t = DesignTokens::default()
{ name: "light", display_name: "浅色", tokens: t, is_dark: false, }
}
///|
pub fn Theme::dark() -> Theme {
let t = DesignTokens::default()
// 覆盖为暗色值
t.colors.set("primary", "#A5B4FC")
t.colors.set("primary_container", "#3B4A8C")
t.colors.set("on_primary", "#1A1C1E")
t.colors.set("on_secondary_container", "#E8EAF6")
t.colors.set("secondary", "#C5CAE9")
t.colors.set("secondary_container", "#3949AB")
t.colors.set("text_on_primary", "#1A1C1E")
t.colors.set("surface", "#1A1C1E")
t.colors.set("surface_variant", "#2A2C2E")
t.colors.set("background", "#0F1114")
t.colors.set("error", "#CF6679")
t.colors.set("error_container", "#5C1A1A")
t.colors.set("success", "#81C784")
t.colors.set("warning", "#FFB74D")
t.colors.set("outline", "#42474F")
t.colors.set("outline_variant", "#2A2C2E")
t.colors.set("text_primary", "#E0E0E0")
t.colors.set("text_secondary", "#A0A5AD")
t.colors.set("text_hint", "#6B7078")
{ name: "dark", display_name: "深色", tokens: t, is_dark: true, }
}
///|
pub fn Theme::high_contrast() -> Theme {
let t = DesignTokens::default()
t.colors.set("primary", "#0000EE")
t.colors.set("on_primary", "#FFFFFF")
t.colors.set("surface", "#FFFFFF")
t.colors.set("surface_variant", "#F0F0F0")
t.colors.set("background", "#FFFFFF")
t.colors.set("error", "#CC0000")
t.colors.set("text_primary", "#000000")
t.colors.set("text_secondary", "#1A1A1A")
t.colors.set("text_hint", "#333333")
t.colors.set("outline", "#000000")
{
name: "high_contrast",
display_name: "高对比度",
tokens: t,
is_dark: false,
}
}
///|
pub fn Theme::sepia() -> Theme {
let t = DesignTokens::default()
t.colors.set("primary", "#8D6E63")
t.colors.set("primary_container", "#EFEBE9")
t.colors.set("on_primary", "#FFFFFF")
t.colors.set("secondary", "#A1887F")
t.colors.set("secondary_container", "#EFEBE9")
t.colors.set("surface", "#FAF6F0")
t.colors.set("surface_variant", "#F5F0E8")
t.colors.set("background", "#FAF6F0")
t.colors.set("error", "#B71C1C")
t.colors.set("text_primary", "#3E2723")
t.colors.set("text_secondary", "#5D4037")
t.colors.set("text_hint", "#8D6E63")
t.colors.set("outline", "#BCAAA4")
{ name: "sepia", display_name: "复古棕", tokens: t, is_dark: false, }
}
///|
pub fn Theme::nord() -> Theme {
let t = DesignTokens::default()
t.colors.set("primary", "#88C0D0")
t.colors.set("primary_container", "#4C566A")
t.colors.set("on_primary", "#2E3440")
t.colors.set("on_secondary_container", "#ECEFF4")
t.colors.set("secondary", "#81A1C1")
t.colors.set("secondary_container", "#434C5E")
t.colors.set("text_on_primary", "#2E3440")
t.colors.set("surface", "#3B4252")
t.colors.set("surface_variant", "#434C5E")
t.colors.set("background", "#2E3440")
t.colors.set("error", "#BF616A")
t.colors.set("success", "#A3BE8C")
t.colors.set("warning", "#EBCB8B")
t.colors.set("outline", "#4C566A")
t.colors.set("outline_variant", "#3B4252")
t.colors.set("text_primary", "#ECEFF4")
t.colors.set("text_secondary", "#D8DEE9")
t.colors.set("text_hint", "#7B88A1")
{ name: "nord", display_name: "Nord 极地", tokens: t, is_dark: true, }
}
///|
pub fn Theme::sunset() -> Theme {
let t = DesignTokens::default()
t.colors.set("primary", "#FF7043")
t.colors.set("primary_container", "#FBE9E7")
t.colors.set("on_primary", "#FFFFFF")
t.colors.set("secondary", "#FF8A65")
t.colors.set("secondary_container", "#FBE9E7")
t.colors.set("surface", "#FFF8E1")
t.colors.set("surface_variant", "#FFECB3")
t.colors.set("background", "#FFF3E0")
t.colors.set("error", "#D32F2F")
t.colors.set("success", "#43A047")
t.colors.set("warning", "#FB8C00")
t.colors.set("outline", "#FFCC80")
t.colors.set("text_primary", "#3E2723")
t.colors.set("text_secondary", "#5D4037")
t.colors.set("text_hint", "#8D6E63")
{ name: "sunset", display_name: "落日暖", tokens: t, is_dark: false, }
}
// ---------------------------------------------------------------------------
// 自动暗色推导
// ---------------------------------------------------------------------------
///|
/// 从主题自动生成暗色版本(RGB 通道反转——即色相旋转 180°,非保留色相的
/// 亮度反演;HSL 语义的反转需要感知色相空间,v1 不引入)
pub fn Theme::auto_dark(base : Theme) -> Theme {
let dark_tokens = base.tokens
// 反转每个颜色令牌的亮度
for key in dark_tokens.colors.keys() {
match dark_tokens.colors.get(key) {
Some(hex) => {
let inverted = invert_luminance(hex)
dark_tokens.colors.set(key, inverted)
}
None => ()
}
}
{
name: base.name + "_auto_dark",
display_name: base.display_name + "(暗色)",
tokens: dark_tokens,
is_dark: true,
}
}
///|
/// RGB 三通道反转(255-v):视觉上是色相旋转 180° 的反相,不是 HSL 意义
/// 的「保色相反亮度」——命名沿用历史,语义以此注释为准
fn invert_luminance(hex : String) -> String {
let (r, g, b) = parse_rgb5(hex)
// 反转 RGB
let nr = 255 - r
let ng = 255 - g
let nb = 255 - b
// 转回 hex
to_hex(nr, ng, nb)
}
///|
fn parse_rgb5(hex : String) -> (Int, Int, Int) {
let body = if hex.length() > 0 && hex.get_char(0) == Some('#') {
hex.substring(start=1)
} else {
hex
}
if body.length() < 6 {
return (0, 0, 0)
}
(hv5(body, 0), hv5(body, 2), hv5(body, 4))
}
///|
fn hv5(s : String, offset : Int) -> Int {
if s.length() < offset + 2 {
return 0
}
let hi = hd5(s.get_char(offset))
let lo = hd5(s.get_char(offset + 1))
hi * 16 + lo
}
///|
fn hd5(c : Char?) -> Int {
match c {
Some(ch) => {
let code = ch.to_int()
if code >= 48 && code <= 57 {
code - 48
} else if code >= 97 && code <= 102 {
code - 87
} else if code >= 65 && code <= 70 {
code - 55
} else {
0
}
}
None => 0
}
}
///|
fn to_hex(r : Int, g : Int, b : Int) -> String {
// 转为 hex
"#" + int_to_hex(r) + int_to_hex(g) + int_to_hex(b)
}
///|
fn int_to_hex(v : Int) -> String {
// Int::to_char 返回 Char?,直接 .to_string() 会产出 "Some(c)" 形态的垃圾
// 十六进制——用 Char::from_int 构造(与 json.mbt write_c0_u_escape 同款)
let hi = v / 16
let lo = v % 16
let sb = StringBuilder::new()
sb.write_char(Char::from_int(if hi < 10 { hi + 48 } else { hi + 87 }))
sb.write_char(Char::from_int(if lo < 10 { lo + 48 } else { lo + 87 }))
sb.to_string()
}
// ---------------------------------------------------------------------------
// Project 集成:主题切换
// ---------------------------------------------------------------------------
///|
/// 应用主题到项目(更新令牌表并重新解析所有节点颜色)
pub fn Project::apply_theme(
self : Project,
theme_name : String,
) -> Result[String, String] {
let themes = builtin_themes()
let mut found : Theme? = None
for i in 0.. {
let prior = self.tokens_snapshot()
self.tokens = theme.tokens
// 主题切换保留自定义令牌覆盖:tokens: 段是设计者明确意图,
// 换主题只换未覆盖的角色
match self.token_overrides {
Some(ov) =>
for role in ov.keys() {
match ov.get(role) {
Some(v) => self.tokens.colors.set(role, v)
None => ()
}
}
None => ()
}
self.active_theme = theme_name
let changed = self.recolor_with(theme.tokens, prior=Some(prior))
let is_dark = if theme.is_dark { "true" } else { "false" }
Ok(
"{\"ok\":true,\"theme\":\"\{theme_name}\",\"is_dark\":\{is_dark},\"recolored\":\{changed}}",
)
}
None => Err("unknown_theme: " + theme_name)
}
}
///|
/// text_color 语境的角色分级(小者优先):文字位置的十六进制值优先按
/// 文字角色解释——#1A1C1E 出现在文字上应视为 light.text_primary 而不是
/// dark.surface,否则 nord 等深色主题会得到「深字压深底」。
fn role_rank_for_text(role : String) -> Int {
if role_has_prefix(role, "text_") {
1
} else if role_has_prefix(role, "on_") {
2
} else if role == "surface" ||
role == "surface_variant" ||
role == "background" {
3
} else if role_has_suffix(role, "_container") {
4
} else if role == "primary" || role == "secondary" {
5
} else if role_has_prefix(role, "outline") {
6
} else {
7
}
}
///|
/// text 语境(彩色底变体):primary/secondary 底上的文字几乎总是
/// on_primary/on_secondary 语义——#FFFFFF 出现在按钮上应还原成
/// on_primary 而不是 surface,否则深色主题往返后按钮文字错位成
/// text_primary 的深色值。
fn role_rank_for_text_on(role : String) -> Int {
if role_has_prefix(role, "on_") {
1
} else if role_has_prefix(role, "text_") {
2
} else {
role_rank_for_text(role)
}
}
///|
/// fill 语境的角色分级:底/容器优先,文字角色殿后(同一字面量被用作
/// 面色时几乎总是 surface/container 语义)。
fn role_rank_for_fill(role : String) -> Int {
if role == "surface" ||
role == "surface_variant" ||
role == "background" {
1
} else if role_has_suffix(role, "_container") {
2
} else if role == "primary" || role == "secondary" {
3
} else if role_has_prefix(role, "outline") {
4
} else if role_has_prefix(role, "text_") {
5
} else if role_has_prefix(role, "on_") {
6
} else {
7
}
}
///|
fn role_has_prefix(s : String, prefix : String) -> Bool {
s.length() >= prefix.length() &&
s.substring(start=0, end=prefix.length()) == prefix
}
///|
fn role_has_suffix(s : String, suffix : String) -> Bool {
s.length() >= suffix.length() &&
s.substring(start=s.length() - suffix.length()) == suffix
}
///|
/// 角色重着色:模板/画布把令牌值写成字面量(fill="#4B6BFB"),仅换
/// tokens 表对已落盘的颜色无效。按默认调色板建立「字面量 → 角色」反查表,
/// 把匹配的 fill/text_color 重写为新主题的角色值——变更写回 .mbt.md,
/// 保持唯一事实源语义。
fn Project::recolor_with(
self : Project,
target : DesignTokens,
prior? : DesignTokens? = None,
) -> Int {
// 字面量 → 角色:以「全部内置主题 ∪ 默认」的调色板并集反查。
// 操作路径每条命令都从 canonical MBT 重建 Project(tokens 恒为默认),
// 仅按当前/默认反查会让「nord → light」无从映射——并集保证任一主题的
// 字面量都能反查到角色,主题切换无状态可逆。
// 字面量 → 候选角色集合。同一字面量在不同主题里可能是不同角色
// (#1A1C1E 既是 light.text_primary 也是 dark.surface)——不能取
// 「最后写入」那个,按使用处(fill / text_color)的角色类分级选义。
let lit_roles : Map[String, Map[String, Bool]] = Map::new()
let remember = fn(lit : String, role : String) {
match lit_roles.get(lit) {
Some(set) => set.set(role, true)
None => {
let set : Map[String, Bool] = Map::new()
set.set(role, true)
lit_roles.set(lit, set)
}
}
}
let palettes : Array[DesignTokens] = [DesignTokens::default()]
for theme in builtin_themes().iter() {
palettes.push(theme.tokens)
}
match prior {
Some(p) => palettes.push(p)
None => ()
}
for palette in palettes {
for role in palette.colors.keys() {
match palette.colors.get(role) {
Some(v) => remember(v, role)
None => ()
}
}
}
// 引擎默认文字色 #111111(node/decl 出厂值)语义上就是 text_primary
remember("#111111", "text_primary")
let pick = fn(lit : String, rank : (String) -> Int) -> String? {
match lit_roles.get(lit) {
None => None
Some(set) => {
let mut best : String? = None
let mut best_rank = 99
for role in set.keys() {
let r = rank(role)
// 严格小于 + 角色名字典序决胜:与 Map 迭代顺序无关,结果确定
if r < best_rank || (r == best_rank && best is Some(b) && role < b) {
best = Some(role)
best_rank = r
}
}
best
}
}
}
let mut changed = 0
for ab_id in self.artboards.keys() {
let ab = match self.artboards.get(ab_id) {
Some(a) => a
None => continue
}
for nid in ab.doc.nodes.keys() {
let _ = ab.doc.update_node(nid, fn(n) {
let mut node = n
match pick(n.style.fill, role_rank_for_fill) {
Some(role) =>
match target.colors.get(role) {
Some(v) => {
if v != n.style.fill {
changed = changed + 1
}
node = { ..node, style: { ..node.style, fill: v, }, }
}
None => ()
}
None => ()
}
// 文字角色按所在底色判别:primary/secondary 实底上的文字优先
// on_*(按钮),透明底/容器上的文字优先 text_*(正文/输入框)
let text_rank = match pick(node.style.fill, role_rank_for_fill) {
Some(r) =>
if r == "primary" || r == "secondary" {
role_rank_for_text_on
} else {
role_rank_for_text
}
None => role_rank_for_text
}
match pick(n.style.text_color, text_rank) {
Some(role) =>
match target.colors.get(role) {
Some(v) => {
if v != n.style.text_color {
changed = changed + 1
}
node = { ..node, style: { ..node.style, text_color: v, }, }
}
None => ()
}
None => ()
}
node
})
}
}
if changed > 0 {
self.revision = self.revision + 1
}
changed
}
///|
/// 令牌表深快照:recolor 反查需要「变更前」的字面量→角色来源,
/// 自定义值(set_token 产物)不在任何内置调色板里。
fn Project::tokens_snapshot(self : Project) -> DesignTokens {
let colors : Map[String, String] = Map::new()
for role in self.tokens.colors.keys() {
match self.tokens.colors.get(role) {
Some(v) => colors.set(role, v)
None => ()
}
}
{ ..self.tokens, colors, }
}
///|
/// 加载路径恢复主题:只切换 tokens 与 active_theme,不做角色重着色——
/// canonical MBT 里的节点颜色已是主题字面量,重着色属于 apply_theme 的
/// 职责,重复执行会把已是目标色的节点原样重写(幂等但徒增 revision)。
pub fn Project::set_theme_tokens(self : Project, theme_name : String) -> Unit {
for theme in builtin_themes().iter() {
if theme.name == theme_name {
self.tokens = theme.tokens
self.active_theme = theme.name
return
}
}
}
///|
/// 修改单个令牌值(全局生效)。
///
/// "全局生效"必须包含两件事,只改内存 tokens 表对已落盘的字面量无效:
/// 1. 记录自定义覆盖(token_overrides),随 canonical MBT frontmatter
/// `tokens:` 段往返——否则下一条命令从文本重建时覆盖即丢失;
/// 2. 立即重着色:旧值字面量 → 角色 → 新值,与 apply_theme 同一反查
/// 表,因此同样可逆(换回原值即恢复)。
pub fn Project::set_token(
self : Project,
token_name : String,
value : String,
) -> Result[String, String] {
if !self.tokens.colors.contains(token_name) {
return Err("unknown_token: " + token_name)
}
let prior = self.tokens_snapshot()
let previous = match self.tokens.colors.get(token_name) {
Some(v) => v
None => ""
}
self.tokens.colors.set(token_name, value)
if self.token_overrides is None {
self.token_overrides = Some(Map::new())
}
match self.token_overrides {
Some(ov) => ov.set(token_name, value)
None => ()
}
// recolor_with 在 changed>0 时已 +1;值未变化的重复 set(load 路径)
// 不会虚涨 revision。
// envelope 合法性红线:value 是 Agent 可控文本,含引号/反斜杠时裸拼
// 会产出非法 JSON(与 fill_slot 同类事故面)——统一过 json_escape
let changed = self.recolor_with(self.tokens, prior=Some(prior))
Ok(
"{\"ok\":true,\"token\":\"\{token_name}\",\"old\":\"\{json_escape(previous)}\",\"value\":\"\{json_escape(value)}\",\"recolored\":\{changed}}",
)
}
///|
/// 获取令牌当前值
pub fn Project::get_token(
self : Project,
token_name : String,
) -> Result[String, String] {
match self.tokens.colors.get(token_name) {
Some(v) => Ok(v)
None => Err("unknown_token: " + token_name)
}
}
///|
/// 预览节点在各主题下的颜色:按默认调色板把节点 fill 反查为角色,再逐
/// 主题取该角色的值(找不到节点或角色时回退 primary)——与 README
/// 「preview the node under each theme」的承诺一致,而非无视 node_id 的
/// 全局 primary 表。
pub fn Project::preview_themes(
self : Project,
node_id : String,
) -> Result[String, String] {
let mut role = "primary"
for ab_id in self.artboards.keys() {
match self.artboards.get(ab_id) {
Some(ab) =>
match ab.doc.get(node_id) {
Some(n) => {
let d = DesignTokens::default()
for r in d.colors.keys() {
match d.colors.get(r) {
Some(v) => if v == n.style.fill {
role = r
}
None => ()
}
}
}
None => ()
}
None => ()
}
}
let results : Array[String] = []
for theme in builtin_themes().iter() {
let color = theme.tokens.resolve_color(role)
results.push(
"{\"theme\":\"\{theme.name}\",\"display\":\"\{theme.display_name}\",\"role\":\"\{role}\",\"color\":\"\{color}\"}",
)
}
let j = jstr21(results)
Ok("[\{j}]")
}
///|
/// 自动生成当前主题的暗色版本并应用。
///
/// 从 active_theme 对应主题推导(而非恒从 light),并与 apply_theme/set_token
/// 对齐状态三元组:tokens / active_theme / token_overrides——派生主题的全部
/// 角色值记入 token_overrides,随 canonical MBT frontmatter `tokens:` 段往返,
/// 节点字面量经 recolor_with 重着色,保持状态与 canonical 一致。
pub fn Project::enable_auto_dark(self : Project) -> Result[String, String] {
let mut base : Theme? = None
for theme in builtin_themes().iter() {
if theme.name == self.active_theme {
base = Some(theme)
break
}
}
let current = match base {
Some(t) => t
None => Theme::light()
}
let dark = Theme::auto_dark(current)
let prior = self.tokens_snapshot()
self.tokens = dark.tokens
// 派生主题的整个令牌面即本次覆盖意图:整体记入 token_overrides
let ov : Map[String, String] = Map::new()
for role in dark.tokens.colors.keys() {
match dark.tokens.colors.get(role) {
Some(v) => ov.set(role, v)
None => ()
}
}
self.token_overrides = Some(ov)
self.active_theme = dark.name
let changed = self.recolor_with(dark.tokens, prior=Some(prior))
Ok(
"{\"ok\":true,\"theme\":\"\{dark.name}\",\"original\":\"\{current.name}\",\"recolored\":\{changed}}",
)
}
///|
fn jstr21(parts : Array[String]) -> String {
let sb = StringBuilder::new()
for i in 0.. 0 {
sb.write_string(",")
}
sb.write_string(parts[i])
}
sb.to_string()
}