Files
my-v0-server/app/actions/layout.py
T
2026-10-08 00:28:29 -07:00

153 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""布局意图展开:算出每个组件的目标位置和尺寸,输出前端已有的 move 和 resize
纯函数,只读大屏;只输出有变化的动作。坐标取整,相邻组件共用同一条边,避免累积误差
"""
from math import ceil
from app.schemas.actions import (
AlignAction,
ArrangeAction,
DistributeAction,
MoveAction,
ResizeAction,
SetLayoutAction,
)
from app.schemas.actions.layout import Area
from app.schemas.screen import Screen, ScreenNode
Primitive = MoveAction | ResizeAction
def _nodes(screen: Screen, ids: list[str]) -> list[ScreenNode]:
by_id = {node.id: node for node in screen.nodes}
if missing := [item for item in ids if item not in by_id]:
raise ValueError(f"目标组件不存在: {'、'.join(missing)}")
return [by_id[item] for item in ids]
def _place(node: ScreenNode, x, y, width, height) -> list[Primitive]:
"""从当前布局变成目标布局,没变的项不输出"""
layout = node.layout
result: list[Primitive] = []
if (x, y) != (layout.x, layout.y):
result.append(
MoveAction(target_id=node.id, delta_x=x - layout.x, delta_y=y - layout.y)
)
if (width, height) != (layout.width, layout.height):
result.append(ResizeAction(target_id=node.id, width=width, height=height))
return result
def _canvas_area(screen: Screen) -> Area:
return Area(x=0, y=0, width=screen.canvas.width, height=screen.canvas.height)
def _bounds(nodes: list[ScreenNode]) -> Area:
left = min(n.layout.x for n in nodes)
top = min(n.layout.y for n in nodes)
right = max(n.layout.x + n.layout.width for n in nodes)
bottom = max(n.layout.y + n.layout.height for n in nodes)
return Area(x=left, y=top, width=right - left, height=bottom - top)
def arrange(screen: Screen, action: ArrangeAction) -> list[Primitive]:
nodes = _nodes(screen, action.target_ids)
count = len(nodes)
columns = {"row": count, "column": 1, "grid": action.columns}[action.mode]
rows = ceil(count / columns)
area = action.area or _canvas_area(screen)
left = area.x + action.padding
top = area.y + action.padding
cell_w = (area.width - 2 * action.padding - action.gap * (columns - 1)) / columns
cell_h = (area.height - 2 * action.padding - action.gap * (rows - 1)) / rows
if cell_w <= 0 or cell_h <= 0:
raise ValueError(
f"区域放不下 {count} 个组件:区域 {area.width:g}x{area.height:g},"
f"边距 {action.padding:g}、间距 {action.gap:g},减小间距或边距"
)
result: list[Primitive] = []
for index, node in enumerate(nodes):
row, column = divmod(index, columns)
x0 = left + column * (cell_w + action.gap)
y0 = top + row * (cell_h + action.gap)
# 取整的是边,不是宽度,相邻单元格不会因为取整多出缝隙或重叠
x, y = round(x0), round(y0)
result += _place(node, x, y, round(x0 + cell_w) - x, round(y0 + cell_h) - y)
return result
def align(screen: Screen, action: AlignAction) -> list[Primitive]:
nodes = _nodes(screen, action.target_ids)
box = _canvas_area(screen) if action.to == "canvas" else _bounds(nodes)
result: list[Primitive] = []
for node in nodes:
layout = node.layout
x, y = layout.x, layout.y
if action.horizontal == "left":
x = box.x
elif action.horizontal == "center":
x = round(box.x + (box.width - layout.width) / 2)
elif action.horizontal == "right":
x = box.x + box.width - layout.width
if action.vertical == "top":
y = box.y
elif action.vertical == "middle":
y = round(box.y + (box.height - layout.height) / 2)
elif action.vertical == "bottom":
y = box.y + box.height - layout.height
result += _place(node, x, y, layout.width, layout.height)
return result
def distribute(screen: Screen, action: DistributeAction) -> list[Primitive]:
horizontal = action.axis == "horizontal"
nodes = sorted(
_nodes(screen, action.target_ids),
key=lambda n: n.layout.x if horizontal else n.layout.y,
)
box = action.area or _bounds(nodes)
start, span = (box.x, box.width) if horizontal else (box.y, box.height)
sizes = [n.layout.width if horizontal else n.layout.height for n in nodes]
if action.gap is not None:
gap = action.gap
elif len(nodes) < 2:
raise ValueError("摊匀间距至少需要 2 个组件")
else:
gap = (span - sum(sizes)) / (len(nodes) - 1)
if gap < 0:
raise ValueError(
f"这些组件的{'宽' if horizontal else '高'}度合计 {sum(sizes):g},"
f"超过可用的 {span:g},排不开"
)
result: list[Primitive] = []
cursor = start
for node, size in zip(nodes, sizes, strict=True):
position = round(cursor)
layout = node.layout
x, y = (position, layout.y) if horizontal else (layout.x, position)
result += _place(node, x, y, layout.width, layout.height)
cursor += size + gap
return result
def set_layout(screen: Screen, action: SetLayoutAction) -> list[Primitive]:
(node,) = _nodes(screen, [action.target_id])
layout = node.layout
return _place(
node,
layout.x if action.x is None else action.x,
layout.y if action.y is None else action.y,
layout.width if action.width is None else action.width,
layout.height if action.height is None else action.height,
)
# 布局意图的 type -> 展开函数;新增意图时在这里注册
EXPANDERS = {
"arrange": arrange,
"align": align,
"distribute": distribute,
"set_layout": set_layout,
}