显式化 UI 编辑器字体来源

新增 SystemFont 与 Bound 字体来源领域枚举并生成 TypeScript 契约

将系统字体统计为已绑定并保留字体资源校验

更新文本编辑器、预览与绑定概览回归测试
This commit is contained in:
2026-08-17 17:32:24 +08:00
parent cb96e6c6cd
commit 6ac2931bb2
12 changed files with 101 additions and 34 deletions
@@ -271,6 +271,7 @@ pub(crate) async fn bind_components_impl(
#[cfg(test)]
mod tests {
use super::*;
use crate::ui_editor::component::text::{FontSource, TextComponent};
use ts_rs::{Config, TS};
fn id(value: &str) -> NodeId {
@@ -326,5 +327,7 @@ mod tests {
let config = Config::from_env();
BindingDTO::export_all(&config).expect("BindingDTO TypeScript export succeeds");
Node::export_all(&config).expect("Node TypeScript export succeeds");
TextComponent::export_all(&config).expect("TextComponent TypeScript export succeeds");
FontSource::export_all(&config).expect("FontSource TypeScript export succeeds");
}
}
@@ -81,12 +81,20 @@ impl Default for FontSizing {
}
}
/// 文本的字体来源。系统字体是一个明确的有效选择,而不是缺失的字体绑定。
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum FontSource {
SystemFont,
Bound(FontAssetId),
}
/// Text / Label 渲染组件;布局由同一 Entity 上独立的 `Control` 提供。
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct TextComponent {
pub content: String,
pub font: Option<FontAssetId>,
pub font: FontSource,
pub font_style: FontStyle,
pub font_sizing: FontSizing,
#[ts(as = "[u8; 4]")]
@@ -103,7 +111,7 @@ impl TextComponent {
pub fn new(content: impl Into<String>) -> Self {
Self {
content: content.into(),
font: None,
font: FontSource::SystemFont,
font_style: FontStyle::Normal,
font_sizing: FontSizing::default(),
color: WHITE,
@@ -120,3 +128,29 @@ impl Default for TextComponent {
Self::new(String::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn system_font_is_an_explicit_serialized_choice() {
let component = TextComponent::default();
assert_eq!(
serde_json::to_value(component).expect("serialize default text component")["font"],
serde_json::json!("SystemFont")
);
}
#[test]
fn bound_font_preserves_its_asset_id() {
let component = TextComponent {
font: FontSource::Bound(FontAssetId::new("font-main").expect("valid font id")),
..TextComponent::default()
};
assert_eq!(
serde_json::to_value(component).expect("serialize bound font")["font"],
serde_json::json!({ "Bound": "font-main" })
);
}
}
@@ -45,7 +45,8 @@ export function getImageBindingCounts(
export function getTextBindingCounts(
component: Extract<Component, { Text: unknown }>['Text'],
): ComponentBindingCounts {
return countRequiredAssetSlot(component.font !== null);
void component;
return countRequiredAssetSlot(true);
}
export function getComponentBindingCounts(
@@ -12,9 +12,7 @@ function imageResourceIssues(state: State): UiEditorPrerequisiteIssue[] {
for (const [id, image] of Object.entries(state.ui_design_images)) {
if (
!image.path.trim() ||
image.pixel_size.some(
(value) => !Number.isFinite(value) || value <= 0,
) ||
image.pixel_size.some((value) => !Number.isFinite(value) || value <= 0) ||
!Number.isFinite(image.pixels_per_unit) ||
image.pixels_per_unit <= 0
) {
@@ -61,9 +59,7 @@ export function validateComponentRecognitionPrerequisites(
message: '界面不能归属于自身',
resourceId: id,
});
} else if (
state.ui_design_images[slaveTo]?.metadata.role !== 'Page'
) {
} else if (state.ui_design_images[slaveTo]?.metadata.role !== 'Page') {
issues.push({
code: 'invalid-slave-to',
message: '归属页面必须是有效主页面',
@@ -123,13 +119,13 @@ export function validateLayoutGenerationPrerequisites(
}
if (
'Text' in component &&
component.Text.font !== null &&
!(component.Text.font in state.font_assets)
typeof component.Text.font !== 'string' &&
!(component.Text.font.Bound in state.font_assets)
) {
issues.push({
code: 'missing-font',
message: '文本组件引用的字体不存在',
resourceId: component.Text.font,
resourceId: component.Text.font.Bound,
});
}
}
@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FontAssetId } from './FontAssetId';
/**
* 文本的字体来源。系统字体是一个明确的有效选择,而不是缺失的字体绑定。
*/
export type FontSource = 'SystemFont' | { Bound: FontAssetId };
@@ -1,12 +1,22 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FontAssetId } from "./FontAssetId";
import type { FontSizing } from "./FontSizing";
import type { FontStyle } from "./FontStyle";
import type { HorizontalTextOverflow } from "./HorizontalTextOverflow";
import type { TextAlignment } from "./TextAlignment";
import type { VerticalTextOverflow } from "./VerticalTextOverflow";
import type { FontSizing } from './FontSizing';
import type { FontSource } from './FontSource';
import type { FontStyle } from './FontStyle';
import type { HorizontalTextOverflow } from './HorizontalTextOverflow';
import type { TextAlignment } from './TextAlignment';
import type { VerticalTextOverflow } from './VerticalTextOverflow';
/**
* Text / Label 渲染组件;布局由同一 Entity 上独立的 `Control` 提供。
*/
export type TextComponent = { content: string, font: FontAssetId | null, font_style: FontStyle, font_sizing: FontSizing, color: [number, number, number, number], alignment: TextAlignment, horizontal_overflow: HorizontalTextOverflow, vertical_overflow: VerticalTextOverflow, line_spacing: number, };
export type TextComponent = {
content: string;
font: FontSource;
font_style: FontStyle;
font_sizing: FontSizing;
color: [number, number, number, number];
alignment: TextAlignment;
horizontal_overflow: HorizontalTextOverflow;
vertical_overflow: VerticalTextOverflow;
line_spacing: number;
};
@@ -5,7 +5,6 @@ import type { Component } from './types/Component';
import type { Node } from './types/Node';
import type { NodeId } from './types/NodeId';
import type { NodeMetadata } from './types/NodeMetadata';
import type { StageStatus } from './types/StageStatus';
import type { SpriteAsset } from './types/SpriteAsset';
import type { SpriteAssetId } from './types/SpriteAssetId';
import type { SpriteBorder } from './types/SpriteBorder';
@@ -344,6 +343,12 @@ function isValidImageType(imageType: unknown): boolean {
return false;
}
function isValidFontSource(font: unknown): boolean {
if (font === 'SystemFont') return true;
const record = asRecord(font);
return record !== null && typeof record.Bound === 'string';
}
function isValidComponent(component: Component): boolean {
if ('Image' in component) {
return (
@@ -370,7 +375,7 @@ function isValidComponent(component: Component): boolean {
text.font_sizing.BestFit.min <= text.font_sizing.BestFit.max);
return (
typeof text.content === 'string' &&
(text.font === null || typeof text.font === 'string') &&
isValidFontSource(text.font) &&
isEnumValue(text.font_style, [
'Normal',
'Bold',
@@ -48,9 +48,9 @@ export function TextPanel({
<ComponentField label="字体资源">
{/* TODO: expose editable imported font assets after FontAsset gets a safe browser resource. */}
<div className="mt-1 rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 py-2 text-xs font-normal">
{component.font
? (fonts[component.font]?.asset_id ?? component.font)
: '未绑定(使用系统字体'}
{typeof component.font !== 'string'
? (fonts[component.font.Bound]?.asset_id ?? component.font.Bound)
: '系统字体'}
</div>
</ComponentField>
<ComponentSelect
@@ -3,7 +3,7 @@ import type { TextComponent } from '../../../../../features/ui-editor/types/Text
export function createDefaultTextComponent(): TextComponent {
return {
content: '',
font: null,
font: 'SystemFont',
font_style: 'Normal',
font_sizing: { Fixed: 14 },
color: [255, 255, 255, 255],
@@ -12,7 +12,8 @@ export function TextComponentView({
}) {
// TODO: FontAsset currently has no browser URL; keep the resource lookup
// explicit so a future @font-face implementation has one integration point.
if (component.font) void resources.fonts[component.font];
if (typeof component.font !== 'string')
void resources.fonts[component.font.Bound];
try {
return (
@@ -21,7 +21,7 @@ function image(targetGraphic: string | null): Component {
};
}
function text(font: string | null): Component {
function text(font: 'SystemFont' | { Bound: string }): Component {
return {
Text: {
content: '文本',
@@ -83,10 +83,15 @@ const sprites = {
const trees: UITree[] = [
{
src_ui_design: 'page-a',
root: node('root', [image('validSprite'), text('font-id')], 'Passed', [
node('review', [image(null)], { NeedReview: '确认素材' }),
node('blocked', [text(null)], 'Blocked'),
]),
root: node(
'root',
[image('validSprite'), text({ Bound: 'font-id' })],
'Passed',
[
node('review', [image(null)], { NeedReview: '确认素材' }),
node('blocked', [text('SystemFont')], 'Blocked'),
],
),
},
];
@@ -95,8 +100,8 @@ describe('getBindingOverview', () => {
expect(getBindingOverview(trees, sprites)).toEqual({
componentsNeedingAssets: 4,
assetSlots: 4,
boundSlots: 2,
pendingSlots: 2,
boundSlots: 3,
pendingSlots: 1,
needsAttention: 2,
blocked: 1,
independentAssets: 2,
@@ -113,7 +118,7 @@ describe('getBindingOverview', () => {
getNextMatchingUiTreeNodeTarget(trees, 'review', (target) =>
nodeHasPendingBinding(target),
)?.node.id,
).toBe('blocked');
).toBe('review');
expect(
getNextMatchingUiTreeNodeTarget(trees, null, nodeNeedsComponentReview)
?.node.id,
@@ -7161,6 +7161,11 @@
- HTTP 传输边界:根 H5 包不得依赖 Tauri guest 插件;AGC 独立包保留 `@tauri-apps/plugin-http`。Rust 插件显式关闭默认特性,只启用 `charset``cookies``http2``rustls-tls`,避免 `reqwest/system-proxy` 通过 Cargo feature union 把画布、Provider、Runtime 与本地回环夹具统一接入 OS 自动系统代理;如未来产品要求正式客户端继承系统代理,必须按各客户端明确设计并单独完成跨平台验证。
- 运行决策:Godot 项目提交给 Project Supervisor 时使用 `standard` Run Profile,避免触发 Web 专用 `game/index.html`、HTTP preview 与自主 Web 完成门。Godot 编辑器启动和内嵌运行预览不在本切片范围。
## 2026-08-17 UI Editor 文本字体来源显式化
- 决策:UI Editor 的 Rust 领域类型以 `FontSource::SystemFont | FontSource::Bound(FontAssetId)` 表达 Text 的字体来源,`SystemFont` 是有效的明确选择,不再以 nullable `font` 承担业务语义;TypeScript 类型必须由 `ts-rs` 从该 Rust 类型生成。
- 统计与校验:两种字体来源都计入一个已完成字体槽;只有 `Bound(id)` 校验对应字体资源存在。缺失图片资源仍是绑定总览中唯一的待处理槽,系统字体文本不进入待处理导航。
## 2026-08-12 AGC Agent 设置分层界面
- `RuntimeConfigDialog` 保持现有配置字段、持久化格式、Runner 读取和 MCP 测试语义,只重构界面信息架构。