"""布局意图:模型只说“怎么排”,坐标由后端算,展开成 move 和 resize 后才交给前端 这些动作只存在于模型提交的这一步,不会出现在返回给前端的动作里,前端协议不变 """ from typing import Annotated, Literal, Self from pydantic import Field, model_validator from app.schemas.screen import Number, PositiveNumber from .base import ActionModel, BaseAction NonNegativeNumber = Annotated[Number, Field(ge=0)] TargetIds = Annotated[ list[str], Field(min_length=1, description="要排列的组件 ID,不能重复"), ] class Area(ActionModel): """画布上的一块矩形区域,坐标单位 px""" x: Number = Field(description="左上角横坐标") y: Number = Field(description="左上角纵坐标") width: PositiveNumber height: PositiveNumber class _Group(ActionModel): target_ids: TargetIds @model_validator(mode="after") def unique_ids(self) -> Self: if len(set(self.target_ids)) != len(self.target_ids): raise ValueError("target_ids 不能重复") return self class ArrangeAction(_Group): """把一组组件平分一块区域:每个组件的位置和尺寸都由这里决定,彼此不重叠、不超出区域 用于“平分空间”“排成一行/一列”“两行两列铺满”等;按 target_ids 的顺序从左到右、从上到下放置 """ type: Literal["arrange"] = "arrange" mode: Literal["row", "column", "grid"] = Field( description="row:排成一行;column:排成一列;grid:按 columns 列排成网格" ) columns: int | None = Field( default=None, ge=1, description="mode=grid 时每行几个,其他模式不填" ) area: Area | None = Field( default=None, description="要铺满的区域,省略表示整个画布" ) gap: NonNegativeNumber = Field(default=20, description="组件之间的间距,单位 px") padding: NonNegativeNumber = Field( default=0, description="区域四周留出的边距,单位 px" ) @model_validator(mode="after") def columns_match_mode(self) -> Self: if (self.mode == "grid") != (self.columns is not None): raise ValueError("mode=grid 时必须填写 columns,其他模式不填") return self class AlignAction(_Group): """按基准对齐,不改变尺寸;居中、靠左、靠右、顶部对齐等""" type: Literal["align"] = "align" to: Literal["canvas", "selection"] = Field( default="canvas", description="canvas:对齐到画布;selection:对齐到这些组件合起来的外框", ) horizontal: Literal["left", "center", "right"] | None = Field( default=None, description="水平方向的对齐方式,省略表示不调整水平位置" ) vertical: Literal["top", "middle", "bottom"] | None = Field( default=None, description="垂直方向的对齐方式,省略表示不调整垂直位置" ) @model_validator(mode="after") def require_direction(self) -> Self: if self.horizontal is None and self.vertical is None: raise ValueError("align 至少提供 horizontal 或 vertical") return self class DistributeAction(_Group): """沿一个方向等间距排开,不改变尺寸;按当前位置的先后顺序排列""" type: Literal["distribute"] = "distribute" axis: Literal["horizontal", "vertical"] gap: NonNegativeNumber | None = Field( default=None, description="固定间距,单位 px,从区域起点(没有 area 时是最前一个组件的位置)依次排开;" "省略表示把间距摊匀,首尾两个组件的外沿不动", ) area: Area | None = Field( default=None, description="在这块区域内排开,省略表示用这些组件合起来的外框" ) class SetLayoutAction(BaseAction): """直接设置组件的绝对位置和尺寸,只写要改的项,其余保持不变;不需要自己换算位移""" type: Literal["set_layout"] = "set_layout" x: Number | None = Field(default=None, description="目标左上角横坐标") y: Number | None = Field(default=None, description="目标左上角纵坐标") width: PositiveNumber | None = Field(default=None, description="目标宽度") height: PositiveNumber | None = Field(default=None, description="目标高度") @model_validator(mode="after") def require_field(self) -> Self: if all(v is None for v in (self.x, self.y, self.width, self.height)): raise ValueError("set_layout 至少提供 x、y、width、height 中的一项") return self