153 lines
5.7 KiB
Python
153 lines
5.7 KiB
Python
"""布局意图展开:算出每个组件的目标位置和尺寸,输出前端已有的 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,
|
||
}
|