设计UI布局编辑器数据结构

参考godot和ugui的api拆分布局, 资源, 样式
实现ts type binding
This commit is contained in:
2026-08-11 19:00:30 +08:00
parent 7052d3e692
commit 8f6573cff0
20 changed files with 1094 additions and 0 deletions
+174
View File
@@ -75,6 +75,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -696,6 +705,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const_fn"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413d67b29ef1021b4d60f4aa1e925ca031751e213832b4b1d588fae623c05c60"
[[package]]
name = "cookie"
version = "0.18.1"
@@ -1639,6 +1654,7 @@ dependencies = [
"image",
"jsonschema",
"libc",
"nalgebra",
"oxc_allocator",
"oxc_ast",
"oxc_ast_visit",
@@ -1664,6 +1680,8 @@ dependencies = [
"tauri-plugin-opener",
"tempfile",
"tokio",
"ts-rs",
"typed_floats",
"unicode-normalization",
"url",
"uuid",
@@ -1764,6 +1782,30 @@ dependencies = [
"winapi",
]
[[package]]
name = "glam"
version = "0.30.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
[[package]]
name = "glam"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7"
[[package]]
name = "glam"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0"
[[package]]
name = "glam"
version = "0.33.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360bd2cd76e0cd9032d42cf2922155cecea2685b0cfa4630c3246df030bcfd6"
[[package]]
name = "glib"
version = "0.18.5"
@@ -2605,6 +2647,16 @@ dependencies = [
"web_atoms",
]
[[package]]
name = "matrixmultiply"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "memchr"
version = "2.8.2"
@@ -2694,6 +2746,37 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nalgebra"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc43a60c217b0c6ff46e47f26911015ad8d2e5a8be1af668c67e370d99a4346"
dependencies = [
"approx",
"glam 0.30.10",
"glam 0.31.1",
"glam 0.32.1",
"glam 0.33.3",
"matrixmultiply",
"nalgebra-macros",
"num-complex",
"num-rational",
"num-traits",
"simba",
"typenum",
]
[[package]]
name = "nalgebra-macros"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "native-tls"
version = "0.2.18"
@@ -4004,6 +4087,12 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -4327,6 +4416,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "safe_arch"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a52ec151f024d703f9fd65abb7cbe81e7cdb39f18917a3a37e3014470dc7c59"
dependencies = [
"bytemuck",
]
[[package]]
name = "same-file"
version = "1.0.6"
@@ -4723,6 +4821,18 @@ dependencies = [
"libc",
]
[[package]]
name = "simba"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1a7200d82ff1c7b4235efd12f11e2537a402c91cf83a5cb97ce80eef787fde7"
dependencies = [
"approx",
"num-complex",
"num-traits",
"wide",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
@@ -5390,6 +5500,15 @@ dependencies = [
"utf-8",
]
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "textwrap"
version = "0.16.2"
@@ -5816,6 +5935,28 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ts-rs"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
dependencies = [
"thiserror 2.0.18",
"ts-rs-macros",
]
[[package]]
name = "ts-rs-macros"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
"termcolor",
]
[[package]]
name = "tungstenite"
version = "0.28.0"
@@ -5833,6 +5974,29 @@ dependencies = [
"utf-8",
]
[[package]]
name = "typed_floats"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "436de2fdc42c0f07890f4b51dcc90a468b0b12280f49caff1cf57ce2e646817c"
dependencies = [
"const_fn",
"rustversion",
"typed_floats_macros",
]
[[package]]
name = "typed_floats_macros"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da27e33675dd7bfc62c7ead8c36779f961815ababe717191d67b75c2356edc4f"
dependencies = [
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.118",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -6384,6 +6548,16 @@ dependencies = [
"libc",
]
[[package]]
name = "wide"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de2aaf408e58689c2096682331b1f42bb2d9f2ed6b11560407d023cd0a6c634e"
dependencies = [
"bytemuck",
"safe_arch",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -15,6 +15,9 @@ shared-contracts = { path = "../../../server-rs/crates/shared-contracts", defaul
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
ts-rs = "12.0.1"
typed_floats = "1.0.7"
nalgebra = "0.35.0"
agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
base64 = "0.22"
chromiumoxide = "0.9.1"
@@ -2482,3 +2482,4 @@ mod diagnostic_log_tests {
#[cfg(test)]
mod tests;
pub mod ui_editor;
@@ -0,0 +1,68 @@
use std::{fs, io, path::PathBuf};
use ts_rs::{Config, TS};
use super::{
component::{
image::{
FillMethod, HorizontalFillOrigin, ImageComponent, ImageType, Radial180Origin,
Radial360Origin, Radial90Origin, VerticalFillOrigin,
},
text::{
BestFitFontSize, FontSizing, FontStyle, HorizontalTextOverflow, TextAlignment,
TextComponent, VerticalTextOverflow,
},
Component,
},
layout::{dimension::UIRect, node::Node, transform::Transform},
resource::{
asset_id::{FontAssetId, SpriteAssetId},
sprite::{SpriteAsset, SpriteBorder},
},
};
const GENERATED_HEADER: &str =
"// This file was generated by ts-rs. Do not edit this file manually.";
fn typescript_bindings() -> String {
let config = Config::default();
let declarations = [
FontAssetId::decl(&config),
SpriteAssetId::decl(&config),
SpriteBorder::decl(&config),
SpriteAsset::decl(&config),
UIRect::decl(&config),
Transform::decl(&config),
HorizontalFillOrigin::decl(&config),
VerticalFillOrigin::decl(&config),
Radial90Origin::decl(&config),
Radial180Origin::decl(&config),
Radial360Origin::decl(&config),
FillMethod::decl(&config),
ImageType::decl(&config),
ImageComponent::decl(&config),
FontStyle::decl(&config),
TextAlignment::decl(&config),
HorizontalTextOverflow::decl(&config),
VerticalTextOverflow::decl(&config),
BestFitFontSize::decl(&config),
FontSizing::decl(&config),
TextComponent::decl(&config),
Component::decl(&config),
Node::decl(&config),
];
format!(
"{GENERATED_HEADER}\n\nexport {}\n",
declarations.join("\n\nexport ")
)
}
fn bindings_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../src/features/ui-editor/types.ts")
}
#[test]
fn export_typescript_bindings() -> io::Result<()> {
fs::write(bindings_path(), typescript_bindings())
}
@@ -0,0 +1,27 @@
use std::{error::Error, fmt};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ComponentValueError {
InvalidAssetId,
NonFinite,
MustBePositive,
MustBeNonNegative,
InvalidRange,
SpriteBorderExceedsSprite,
}
impl fmt::Display for ComponentValueError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::InvalidAssetId => "资源 ID 不能为空或包含首尾空白",
Self::NonFinite => "数值必须是有限数",
Self::MustBePositive => "数值必须大于零",
Self::MustBeNonNegative => "数值不能小于零",
Self::InvalidRange => "数值范围无效",
Self::SpriteBorderExceedsSprite => "九宫格边距不能超过 Sprite 尺寸",
};
formatter.write_str(message)
}
}
impl Error for ComponentValueError {}
@@ -0,0 +1 @@
pub mod error;
@@ -0,0 +1,213 @@
use std::{error::Error, fmt};
use crate::ui_editor::component::common::error::ComponentValueError;
use crate::ui_editor::layout::transform::Transform;
use crate::ui_editor::resource::sprite::SpriteAsset;
use nalgebra::Vector2;
use ts_rs::TS;
use typed_floats::tf32::StrictlyPositiveFinite;
#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)]
pub enum HorizontalFillOrigin {
Left,
Right,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)]
pub enum VerticalFillOrigin {
Bottom,
Top,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)]
pub enum Radial90Origin {
BottomLeft,
TopLeft,
TopRight,
BottomRight,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)]
pub enum Radial180Origin {
Bottom,
Left,
Top,
Right,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)]
pub enum Radial360Origin {
Bottom,
Right,
Top,
Left,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)]
pub enum FillMethod {
Horizontal(HorizontalFillOrigin),
Vertical(VerticalFillOrigin),
Radial90 {
origin: Radial90Origin,
clockwise: bool,
},
Radial180 {
origin: Radial180Origin,
clockwise: bool,
},
Radial360 {
origin: Radial360Origin,
clockwise: bool,
},
}
fn validate_fill_amount(amount: f32) -> Result<(), ComponentValueError> {
if !amount.is_finite() {
return Err(ComponentValueError::NonFinite);
}
if !(0.0..=1.0).contains(&amount) {
return Err(ComponentValueError::InvalidRange);
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, TS)]
pub enum ImageType {
Simple {
preserve_aspect: bool,
},
Sliced {
fill_center: bool,
#[ts(as = "f32")]
pixels_per_unit_multiplier: StrictlyPositiveFinite,
},
Tiled {
fill_center: bool,
#[ts(as = "f32")]
pixels_per_unit_multiplier: StrictlyPositiveFinite,
},
Filled {
preserve_aspect: bool,
method: FillMethod,
amount: f32,
},
}
impl ImageType {
pub const fn sliced(
fill_center: bool,
pixels_per_unit_multiplier: StrictlyPositiveFinite,
) -> Self {
Self::Sliced {
fill_center,
pixels_per_unit_multiplier,
}
}
pub const fn tiled(
fill_center: bool,
pixels_per_unit_multiplier: StrictlyPositiveFinite,
) -> Self {
Self::Tiled {
fill_center,
pixels_per_unit_multiplier,
}
}
pub fn filled(
preserve_aspect: bool,
method: FillMethod,
amount: f32,
) -> Result<Self, ComponentValueError> {
validate_fill_amount(amount)?;
Ok(Self::Filled {
preserve_aspect,
method,
amount,
})
}
pub fn supports_native_size(self) -> bool {
matches!(self, Self::Simple { .. } | Self::Filled { .. })
}
pub fn uses_sprite_border(self) -> bool {
matches!(self, Self::Sliced { .. } | Self::Tiled { .. })
}
pub fn tiles_center(self) -> bool {
matches!(
self,
Self::Tiled {
fill_center: true,
..
}
)
}
}
impl Default for ImageType {
fn default() -> Self {
Self::Simple {
preserve_aspect: false,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SetNativeSizeError {
MissingSprite,
UnsupportedImageType,
}
impl fmt::Display for SetNativeSizeError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::MissingSprite => "Image 没有 Target Graphic Sprite",
Self::UnsupportedImageType => "只有 Simple 或 Filled Image 支持 Set Native Size",
};
formatter.write_str(message)
}
}
impl Error for SetNativeSizeError {}
/// Image 渲染组件;布局由同一 Entity 上独立的 `Control` 提供。
#[derive(Clone, Debug, PartialEq, TS)]
pub struct ImageComponent {
pub target_graphic: Option<SpriteAsset>,
pub image_type: ImageType,
}
impl ImageComponent {
pub fn new() -> Self {
Self {
target_graphic: None,
image_type: ImageType::default(),
}
}
/// 跨组件把布局重置为 Sprite 的原始尺寸。
pub fn set_native_size(
&self,
control: &mut Transform,
) -> Result<Vector2<f32>, SetNativeSizeError> {
if !self.image_type.supports_native_size() {
return Err(SetNativeSizeError::UnsupportedImageType);
}
let native_size = self
.target_graphic
.as_ref()
.ok_or(SetNativeSizeError::MissingSprite)?
.native_size();
control.anchor_max = control.anchor_min;
control.offset_max = control.offset_min + native_size;
Ok(native_size)
}
}
impl Default for ImageComponent {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,8 @@
pub mod common;
pub mod image;
pub mod text;
#[derive(ts_rs::TS)]
pub enum Component {
Image(image::ImageComponent),
Text(text::TextComponent),
}
@@ -0,0 +1,112 @@
use crate::ui_editor::component::common::error::ComponentValueError;
use crate::ui_editor::resource::asset_id::FontAssetId;
use crate::ui_editor::resource::sprite::{Color, WHITE};
use std::num::NonZeroU32;
use ts_rs::TS;
use typed_floats::tf32::StrictlyPositiveFinite;
#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)]
pub enum FontStyle {
Normal,
Bold,
Italic,
BoldItalic,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)]
pub enum TextAlignment {
UpperLeft,
UpperCenter,
UpperRight,
MiddleLeft,
MiddleCenter,
MiddleRight,
LowerLeft,
LowerCenter,
LowerRight,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)]
pub enum HorizontalTextOverflow {
Wrap,
Overflow,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, TS)]
pub enum VerticalTextOverflow {
Truncate,
Overflow,
}
#[derive(Clone, Copy, Debug, PartialEq, TS)]
pub struct BestFitFontSize {
min: NonZeroU32,
max: NonZeroU32,
}
impl BestFitFontSize {
pub fn new(min: NonZeroU32, max: NonZeroU32) -> Result<Self, ComponentValueError> {
if min > max {
return Err(ComponentValueError::InvalidRange);
}
Ok(Self { min, max })
}
pub fn min(self) -> NonZeroU32 {
self.min
}
pub fn max(self) -> NonZeroU32 {
self.max
}
}
#[derive(Clone, Copy, Debug, PartialEq, TS)]
pub enum FontSizing {
Fixed(NonZeroU32),
BestFit(BestFitFontSize),
}
impl Default for FontSizing {
fn default() -> Self {
Self::Fixed(NonZeroU32::new(14).expect("14 is non-zero"))
}
}
/// Text / Label 渲染组件;布局由同一 Entity 上独立的 `Control` 提供。
#[derive(Clone, Debug, PartialEq, TS)]
pub struct TextComponent {
pub content: String,
pub font: Option<FontAssetId>,
pub font_style: FontStyle,
pub font_sizing: FontSizing,
#[ts(as = "[u8; 4]")]
pub color: Color,
pub alignment: TextAlignment,
pub horizontal_overflow: HorizontalTextOverflow,
pub vertical_overflow: VerticalTextOverflow,
#[ts(as = "f32")]
pub line_spacing: StrictlyPositiveFinite,
}
impl TextComponent {
pub fn new(content: impl Into<String>) -> Self {
Self {
content: content.into(),
font: None,
font_style: FontStyle::Normal,
font_sizing: FontSizing::default(),
color: WHITE,
alignment: TextAlignment::UpperLeft,
horizontal_overflow: HorizontalTextOverflow::Wrap,
vertical_overflow: VerticalTextOverflow::Truncate,
line_spacing: StrictlyPositiveFinite::new(1.0).expect("1.0 is positive and finite"),
}
}
}
impl Default for TextComponent {
fn default() -> Self {
Self::new(String::new())
}
}
@@ -0,0 +1,2 @@
pub mod ui_design_image;
@@ -0,0 +1,10 @@
pub enum UIDesignImageRole{
Page,
Section,
Modal,
Drawer,
Popover,
State,
Scrolled,
Detail,
}
@@ -0,0 +1,37 @@
use nalgebra::{Point2, Vector2};
use ts_rs::TS;
/// UI 布局在父级坐标系中的轴对齐矩形。
///
/// `min` 是矩形的最小角,`size` 是沿两个坐标轴的尺寸。这里不规定 Y 轴方向,
/// 因而既能用于 Y 轴向上的游戏坐标,也能用于 Y 轴向下的画布坐标。
#[derive(Clone, Copy, Debug, PartialEq, TS)]
pub struct UIRect {
#[ts(as = "[f32; 2]")]
pub min: Point2<f32>,
#[ts(as = "[f32; 2]")]
pub size: Vector2<f32>,
}
impl UIRect {
pub fn new(min: Point2<f32>, size: Vector2<f32>) -> Self {
Self { min, size }
}
pub fn from_min_max(min: Point2<f32>, max: Point2<f32>) -> Self {
Self::new(min, max - min)
}
pub fn max(&self) -> Point2<f32> {
self.min + self.size
}
pub fn center(&self) -> Point2<f32> {
self.min + self.size * 0.5
}
/// 把矩形内部的归一化坐标转换为当前坐标系中的点。
pub fn point_at(&self, normalized_position: Vector2<f32>) -> Point2<f32> {
self.min + self.size.component_mul(&normalized_position)
}
}
@@ -0,0 +1,3 @@
pub mod dimension;
pub mod node;
pub mod transform;
@@ -0,0 +1,10 @@
use crate::ui_editor::component::Component;
use crate::ui_editor::layout::transform::Transform;
use ts_rs::TS;
#[derive(TS)]
pub struct Node {
transform: Transform,
components: Vec<Component>,
children: Vec<Node>,
}
@@ -0,0 +1,213 @@
use crate::ui_editor::layout::dimension::UIRect;
use nalgebra::Vector2;
use ts_rs::TS;
#[derive(Clone, Copy, Debug, PartialEq, TS)]
pub struct Transform {
#[ts(as = "[f32; 2]")]
pub anchor_min: Vector2<f32>,
#[ts(as = "[f32; 2]")]
pub anchor_max: Vector2<f32>,
#[ts(as = "[f32; 2]")]
pub offset_min: Vector2<f32>,
#[ts(as = "[f32; 2]")]
pub offset_max: Vector2<f32>,
}
impl Transform {
pub fn new(
anchor_min: Vector2<f32>,
anchor_max: Vector2<f32>,
offset_min: Vector2<f32>,
offset_max: Vector2<f32>,
) -> Self {
Self {
anchor_min,
anchor_max,
offset_min,
offset_max,
}
}
/// 创建中心锚定、指定尺寸的矩形。
pub fn centered(size: Vector2<f32>) -> Self {
let half_size = size * 0.5;
Self::new(
Vector2::new(0.5, 0.5),
Vector2::new(0.5, 0.5),
-half_size,
half_size,
)
}
/// 创建铺满父矩形的拉伸布局。
pub fn stretch() -> Self {
Self::new(
Vector2::zeros(),
Vector2::repeat(1.0),
Vector2::zeros(),
Vector2::zeros(),
)
}
/// 锚点在父矩形中覆盖的尺寸。
pub fn anchor_span_size(&self, parent: &UIRect) -> Vector2<f32> {
parent
.size
.component_mul(&(self.anchor_max - self.anchor_min))
}
/// 在给定父矩形下得到最终尺寸。
pub fn resolved_size(&self, parent: &UIRect) -> Vector2<f32> {
self.anchor_span_size(parent) + self.offset_max - self.offset_min
}
/// 在给定父矩形下解析出最终轴对齐矩形。
pub fn resolve(&self, parent: &UIRect) -> UIRect {
let min = parent.point_at(self.anchor_min) + self.offset_min;
let max = parent.point_at(self.anchor_max) + self.offset_max;
UIRect::from_min_max(min, max)
}
/// 调整 offsets,使解析结果匹配目标矩形,anchors 保持不变。
pub fn set_resolved_rect(&mut self, parent: &UIRect, rect: UIRect) {
self.offset_min = rect.min - parent.point_at(self.anchor_min);
self.offset_max = rect.max() - parent.point_at(self.anchor_max);
}
/// 修改 anchors,同时保持当前可见矩形不变。
pub fn set_anchors_preserving_rect(
&mut self,
parent: &UIRect,
anchor_min: Vector2<f32>,
anchor_max: Vector2<f32>,
) {
let rect = self.resolve(parent);
self.anchor_min = anchor_min;
self.anchor_max = anchor_max;
self.set_resolved_rect(parent, rect);
}
/// 保持最小边不变,直接设置最终尺寸。
pub fn set_size(&mut self, size: Vector2<f32>, parent: &UIRect) {
self.offset_max = self.offset_min + size - self.anchor_span_size(parent);
}
}
impl Default for Transform {
fn default() -> Self {
Self::centered(Vector2::new(100.0, 100.0))
}
}
#[cfg(test)]
mod tests {
use crate::ui_editor::layout::dimension::UIRect;
use crate::ui_editor::layout::transform::Transform;
use nalgebra::{Point2, Vector2};
fn assert_vector_close(actual: Vector2<f32>, expected: Vector2<f32>) {
assert!(
(actual - expected).norm() <= 1.0e-4,
"actual={actual:?}, expected={expected:?}"
);
}
fn assert_point_close(actual: Point2<f32>, expected: Point2<f32>) {
assert_vector_close(actual - expected, Vector2::zeros());
}
fn assert_rect_close(actual: UIRect, expected: UIRect) {
assert_point_close(actual.min, expected.min);
assert_vector_close(actual.size, expected.size);
}
#[test]
fn fixed_anchors_use_offsets_as_final_rect() {
let parent = UIRect::new(Point2::new(10.0, 20.0), Vector2::new(800.0, 600.0));
let control = Transform::new(
Vector2::new(0.5, 0.5),
Vector2::new(0.5, 0.5),
Vector2::new(-20.0, -30.0),
Vector2::new(80.0, 10.0),
);
assert_rect_close(
control.resolve(&parent),
UIRect::new(Point2::new(390.0, 290.0), Vector2::new(100.0, 40.0)),
);
}
#[test]
fn split_anchors_add_parent_span_to_offsets() {
let parent = UIRect::new(Point2::new(10.0, 20.0), Vector2::new(800.0, 600.0));
let control = Transform::new(
Vector2::new(0.1, 0.2),
Vector2::new(0.9, 0.8),
Vector2::new(10.0, 25.0),
Vector2::new(-10.0, -15.0),
);
assert_rect_close(
control.resolve(&parent),
UIRect::new(Point2::new(100.0, 165.0), Vector2::new(620.0, 320.0)),
);
}
#[test]
fn stretch_fills_a_parent_with_a_non_zero_origin() {
let parent = UIRect::new(Point2::new(-20.0, 35.0), Vector2::new(320.0, 180.0));
assert_rect_close(Transform::stretch().resolve(&parent), parent);
}
#[test]
fn target_rect_can_be_converted_back_to_layout_properties() {
let parent = UIRect::new(Point2::new(100.0, -50.0), Vector2::new(900.0, 500.0));
let expected = UIRect::new(Point2::new(240.0, 80.0), Vector2::new(360.0, 140.0));
let mut control = Transform::new(
Vector2::new(0.2, 0.1),
Vector2::new(0.8, 0.9),
Vector2::zeros(),
Vector2::zeros(),
);
control.set_resolved_rect(&parent, expected);
assert_rect_close(control.resolve(&parent), expected);
}
#[test]
fn changing_anchors_can_preserve_the_visible_rect() {
let parent = UIRect::new(Point2::new(20.0, 30.0), Vector2::new(1280.0, 720.0));
let mut control = Transform::new(
Vector2::new(0.0, 0.0),
Vector2::new(0.0, 0.0),
Vector2::new(120.0, 80.0),
Vector2::new(420.0, 240.0),
);
let expected = control.resolve(&parent);
control.set_anchors_preserving_rect(
&parent,
Vector2::new(0.2, 0.3),
Vector2::new(0.8, 0.7),
);
assert_rect_close(control.resolve(&parent), expected);
}
#[test]
fn set_size_accounts_for_parent_anchor_span() {
let parent = UIRect::new(Point2::origin(), Vector2::new(1000.0, 600.0));
let mut control = Transform::new(
Vector2::new(0.25, 0.2),
Vector2::new(0.75, 0.8),
Vector2::zeros(),
Vector2::zeros(),
);
control.set_size(Vector2::new(640.0, 240.0), &parent);
assert_vector_close(control.resolved_size(&parent), Vector2::new(640.0, 240.0));
}
}
@@ -0,0 +1,7 @@
pub mod component;
pub mod layout;
pub mod resource;
#[cfg(test)]
mod bindings;
pub mod import;
@@ -0,0 +1,31 @@
use crate::ui_editor::component::common::error::ComponentValueError;
macro_rules! define_asset_id {
($name:ident) => {
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, ts_rs::TS)]
pub struct $name(String);
impl $name {
pub fn new(value: impl Into<String>) -> Result<Self, ComponentValueError> {
let value = value.into();
if value.is_empty() || value.trim() != value {
return Err(ComponentValueError::InvalidAssetId);
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
self.as_str()
}
}
};
}
define_asset_id!(FontAssetId);
define_asset_id!(SpriteAssetId);
@@ -0,0 +1,2 @@
pub mod asset_id;
pub mod sprite;
@@ -0,0 +1,125 @@
use crate::ui_editor::component::common::error::ComponentValueError;
use crate::ui_editor::resource::asset_id::SpriteAssetId;
use image::Rgba;
use nalgebra::Vector2;
use ts_rs::TS;
use typed_floats::tf32::StrictlyPositiveFinite;
pub type Color = Rgba<u8>;
pub(in crate::ui_editor) const WHITE: Color = Rgba([255, 255, 255, 255]);
#[derive(Clone, Copy, Debug, PartialEq, TS)]
pub struct SpriteBorder {
left: f32,
right: f32,
top: f32,
bottom: f32,
}
impl SpriteBorder {
pub const NONE: Self = Self {
left: 0.0,
right: 0.0,
top: 0.0,
bottom: 0.0,
};
pub fn new(left: f32, right: f32, top: f32, bottom: f32) -> Result<Self, ComponentValueError> {
let values = [left, right, top, bottom];
if values.iter().any(|value| !value.is_finite()) {
return Err(ComponentValueError::NonFinite);
}
if values.iter().any(|value| *value < 0.0) {
return Err(ComponentValueError::MustBeNonNegative);
}
Ok(Self {
left,
right,
top,
bottom,
})
}
pub fn left(self) -> f32 {
self.left
}
pub fn right(self) -> f32 {
self.right
}
pub fn top(self) -> f32 {
self.top
}
pub fn bottom(self) -> f32 {
self.bottom
}
pub fn has_border(self) -> bool {
self != Self::NONE
}
}
impl Default for SpriteBorder {
fn default() -> Self {
Self::NONE
}
}
/// UI Sprite 引用以及 `Set Native Size` / 9-slice 所需的导入元数据。
#[derive(Clone, Debug, PartialEq, TS)]
pub struct SpriteAsset {
asset_id: SpriteAssetId,
#[ts(as = "[f32; 2]")]
pixel_size: Vector2<f32>,
#[ts(as = "f32")]
pixels_per_unit: StrictlyPositiveFinite,
border: SpriteBorder,
}
impl SpriteAsset {
pub fn new(
asset_id: SpriteAssetId,
pixel_size: Vector2<f32>,
pixels_per_unit: StrictlyPositiveFinite,
border: SpriteBorder,
) -> Result<Self, ComponentValueError> {
if !pixel_size.x.is_finite() || !pixel_size.y.is_finite() {
return Err(ComponentValueError::NonFinite);
}
if pixel_size.x <= 0.0 || pixel_size.y <= 0.0 {
return Err(ComponentValueError::MustBePositive);
}
if border.left + border.right > pixel_size.x || border.top + border.bottom > pixel_size.y {
return Err(ComponentValueError::SpriteBorderExceedsSprite);
}
Ok(Self {
asset_id,
pixel_size,
pixels_per_unit,
border,
})
}
pub fn asset_id(&self) -> &SpriteAssetId {
&self.asset_id
}
pub fn pixel_size(&self) -> Vector2<f32> {
self.pixel_size
}
pub fn pixels_per_unit(&self) -> StrictlyPositiveFinite {
self.pixels_per_unit
}
pub fn border(&self) -> SpriteBorder {
self.border
}
pub fn native_size(&self) -> Vector2<f32> {
self.pixel_size / self.pixels_per_unit.get()
}
}
@@ -0,0 +1,47 @@
// This file was generated by ts-rs. Do not edit this file manually.
export type FontAssetId = string;
export type SpriteAssetId = string;
export type SpriteBorder = { left: number, right: number, top: number, bottom: number, };
export type SpriteAsset = { asset_id: SpriteAssetId, pixel_size: [number, number], pixels_per_unit: number, border: SpriteBorder, };
export type UIRect = { min: [number, number], size: [number, number], };
export type Transform = { anchor_min: [number, number], anchor_max: [number, number], offset_min: [number, number], offset_max: [number, number], };
export type HorizontalFillOrigin = "Left" | "Right";
export type VerticalFillOrigin = "Bottom" | "Top";
export type Radial90Origin = "BottomLeft" | "TopLeft" | "TopRight" | "BottomRight";
export type Radial180Origin = "Bottom" | "Left" | "Top" | "Right";
export type Radial360Origin = "Bottom" | "Right" | "Top" | "Left";
export type FillMethod = { "Horizontal": HorizontalFillOrigin } | { "Vertical": VerticalFillOrigin } | { "Radial90": { origin: Radial90Origin, clockwise: boolean, } } | { "Radial180": { origin: Radial180Origin, clockwise: boolean, } } | { "Radial360": { origin: Radial360Origin, clockwise: boolean, } };
export type ImageType = { "Simple": { preserve_aspect: boolean, } } | { "Sliced": { fill_center: boolean, pixels_per_unit_multiplier: number, } } | { "Tiled": { fill_center: boolean, pixels_per_unit_multiplier: number, } } | { "Filled": { preserve_aspect: boolean, method: FillMethod, amount: number, } };
export type ImageComponent = { target_graphic: SpriteAsset | null, image_type: ImageType, };
export type FontStyle = "Normal" | "Bold" | "Italic" | "BoldItalic";
export type TextAlignment = "UpperLeft" | "UpperCenter" | "UpperRight" | "MiddleLeft" | "MiddleCenter" | "MiddleRight" | "LowerLeft" | "LowerCenter" | "LowerRight";
export type HorizontalTextOverflow = "Wrap" | "Overflow";
export type VerticalTextOverflow = "Truncate" | "Overflow";
export type BestFitFontSize = { min: number, max: number, };
export type FontSizing = { "Fixed": number } | { "BestFit": BestFitFontSize };
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 Component = { "Image": ImageComponent } | { "Text": TextComponent };
export type Node = { transform: Transform, components: Array<Component>, children: Array<Node>, };