显式化 UI 编辑器字体来源
新增 SystemFont 与 Bound 字体来源领域枚举并生成 TypeScript 契约 将系统字体统计为已绑定并保留字体资源校验 更新文本编辑器、预览与绑定概览回归测试
This commit is contained in:
@@ -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',
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+1
-1
@@ -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],
|
||||
|
||||
+2
-1
@@ -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 测试语义,只重构界面信息架构。
|
||||
|
||||
Reference in New Issue
Block a user