Files
pax 0d3ca3ec74 fix(gui): keep panel_layout Qt-free so CI can import it
CI installs no PySide6; overlay moved to layout_overlay.py
2026-09-23 00:04:59 -05:00

145 lines
4.8 KiB
Python

"""Rearrangeable main-window panels: the layout model (pure).
A layout is a list of columns, each a list of panel names stacked top
to bottom, stored as ``"results,preview/info"`` (``,`` between columns,
``/`` between stacked panels). Sizes are stored by name, not index:
column widths under the column's own spec, stacked heights under the
panel, so a width follows its panel through any reorder.
No Qt imports here, so the tests run without PySide6 (as in CI). The
drag overlay lives in layout_overlay.
"""
from __future__ import annotations
PANELS = ("results", "preview", "info")
PANEL_TITLES = {"results": "Results", "preview": "Preview", "info": "Info"}
PANEL_MIME = "application/x-booru-viewer-panel"
DEFAULT_WIDTH = {"results": 600, "preview": 500, "info": 250}
DEFAULT_HEIGHT = {"results": 500, "preview": 500, "info": 200}
Layout = list[list[str]]
Rect = tuple[int, int, int, int]
# -- Model (pure) --
def default_layout(flip: bool) -> Layout:
"""The pre-drag arrangement, honoring the retired flip_layout setting."""
return [["preview", "info"], ["results"]] if flip else [["results"], ["preview", "info"]]
def parse_layout(s: str) -> Layout | None:
"""Parse a stored layout; None unless every panel appears exactly once."""
if not s:
return None
layout = [col.split("/") for col in s.split(",")]
names = [p for col in layout for p in col]
if sorted(names) != sorted(PANELS):
return None
return layout
def format_layout(layout: Layout) -> str:
return ",".join(column_key(col) for col in layout)
def column_key(col: list[str]) -> str:
return "/".join(col)
def move_panel(layout: Layout, panel: str, target: str, zone: str) -> Layout:
"""Return *layout* with *panel* dropped on *zone* of *target*.
left/right make *panel* its own column beside target's column;
top/bottom stack it in target's column.
"""
if panel == target:
return layout
cols = [[p for p in col if p != panel] for col in layout]
cols = [col for col in cols if col]
ci = next(i for i, col in enumerate(cols) if target in col)
ri = cols[ci].index(target)
if zone == "left":
cols.insert(ci, [panel])
elif zone == "right":
cols.insert(ci + 1, [panel])
elif zone == "top":
cols[ci].insert(ri, panel)
else:
cols[ci].insert(ri + 1, panel)
return cols
def parse_sizes(s: str) -> dict[str, int]:
"""``"results:600,preview/info:500"`` to a dict; bad entries are skipped."""
out: dict[str, int] = {}
for part in (s or "").split(","):
key, _, val = part.partition(":")
try:
size = int(val)
except ValueError:
continue
if key and size > 0:
out[key] = size
return out
def format_sizes(sizes: dict[str, int]) -> str:
return ",".join(f"{k}:{v}" for k, v in sizes.items())
def legacy_sizes(flip: bool, main: str, right: str) -> tuple[dict[str, int], dict[str, int]]:
"""Seed name-keyed sizes from the pre-drag index-keyed settings.
main_splitter_sizes was (grid, right column) in screen order and
right_splitter_sizes was (preview, dl_progress, info). Both keys are
left untouched so an older build still finds them.
"""
widths: dict[str, int] = {}
heights: dict[str, int] = {}
m = [int(p) for p in main.split(",") if p.strip().isdigit()] if main else []
if len(m) == 2:
right_w, grid_w = (m[0], m[1]) if flip else (m[1], m[0])
if grid_w > 0:
widths["results"] = grid_w
if right_w > 0:
widths["preview/info"] = right_w
r = [int(p) for p in right.split(",") if p.strip().isdigit()] if right else []
if len(r) == 3:
if r[0] > 0:
heights["preview"] = r[0]
if r[2] > 0:
heights["info"] = r[2]
return widths, heights
def zone_rects(x: int, y: int, w: int, h: int) -> dict[str, Rect]:
"""Drop zones inside one panel's rect; the centre is not a zone."""
side, band = w // 4, h * 2 // 5
return {
"left": (x, y, side, h),
"right": (x + w - side, y, side, h),
"top": (x + side, y, w - 2 * side, band),
"bottom": (x + side, y + h - band, w - 2 * side, band),
}
def hit_zone(rects: dict[str, Rect], px: int, py: int, exclude: str | None) -> tuple[str, str] | None:
"""(panel, zone) under (px, py), skipping the panel being dragged."""
for name, rect in rects.items():
if name == exclude:
continue
for zone, (zx, zy, zw, zh) in zone_rects(*rect).items():
if zx <= px < zx + zw and zy <= py < zy + zh:
return name, zone
return None
def grip_rect(rect: Rect) -> Rect:
"""Edit-mode grip label, centred near the top of a panel."""
x, y, w, _ = rect
gw = min(140, w - 8)
return (x + (w - gw) // 2, y + 12, gw, 28)