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