Implement pane.resize divider control and verify in tmux matrix test (#223)
This commit is contained in:
parent
04431751ce
commit
d9b7511b07
2 changed files with 224 additions and 12 deletions
|
|
@ -2906,20 +2906,187 @@ class TerminalController {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum V2PaneResizeDirection: String {
|
||||||
|
case left
|
||||||
|
case right
|
||||||
|
case up
|
||||||
|
case down
|
||||||
|
|
||||||
|
var splitOrientation: String {
|
||||||
|
switch self {
|
||||||
|
case .left, .right:
|
||||||
|
return "horizontal"
|
||||||
|
case .up, .down:
|
||||||
|
return "vertical"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A split controls the target pane's right/bottom edge when target is first child,
|
||||||
|
/// and left/top edge when target is second child.
|
||||||
|
var requiresPaneInFirstChild: Bool {
|
||||||
|
switch self {
|
||||||
|
case .right, .down:
|
||||||
|
return true
|
||||||
|
case .left, .up:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Positive value moves divider toward second child (right/down).
|
||||||
|
var dividerDeltaSign: CGFloat {
|
||||||
|
requiresPaneInFirstChild ? 1 : -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct V2PaneResizeCandidate {
|
||||||
|
let splitId: UUID
|
||||||
|
let orientation: String
|
||||||
|
let paneInFirstChild: Bool
|
||||||
|
let dividerPosition: CGFloat
|
||||||
|
let axisPixels: CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct V2PaneResizeTrace {
|
||||||
|
let containsTarget: Bool
|
||||||
|
let bounds: CGRect
|
||||||
|
}
|
||||||
|
|
||||||
|
private func v2PaneResizeCollectCandidates(
|
||||||
|
node: ExternalTreeNode,
|
||||||
|
targetPaneId: String,
|
||||||
|
candidates: inout [V2PaneResizeCandidate]
|
||||||
|
) -> V2PaneResizeTrace {
|
||||||
|
switch node {
|
||||||
|
case .pane(let pane):
|
||||||
|
let bounds = CGRect(
|
||||||
|
x: pane.frame.x,
|
||||||
|
y: pane.frame.y,
|
||||||
|
width: pane.frame.width,
|
||||||
|
height: pane.frame.height
|
||||||
|
)
|
||||||
|
return V2PaneResizeTrace(containsTarget: pane.id == targetPaneId, bounds: bounds)
|
||||||
|
|
||||||
|
case .split(let split):
|
||||||
|
let first = v2PaneResizeCollectCandidates(
|
||||||
|
node: split.first,
|
||||||
|
targetPaneId: targetPaneId,
|
||||||
|
candidates: &candidates
|
||||||
|
)
|
||||||
|
let second = v2PaneResizeCollectCandidates(
|
||||||
|
node: split.second,
|
||||||
|
targetPaneId: targetPaneId,
|
||||||
|
candidates: &candidates
|
||||||
|
)
|
||||||
|
|
||||||
|
let combinedBounds = first.bounds.union(second.bounds)
|
||||||
|
let containsTarget = first.containsTarget || second.containsTarget
|
||||||
|
|
||||||
|
if containsTarget,
|
||||||
|
let splitUUID = UUID(uuidString: split.id) {
|
||||||
|
let orientation = split.orientation.lowercased()
|
||||||
|
let axisPixels: CGFloat = orientation == "horizontal"
|
||||||
|
? combinedBounds.width
|
||||||
|
: combinedBounds.height
|
||||||
|
candidates.append(V2PaneResizeCandidate(
|
||||||
|
splitId: splitUUID,
|
||||||
|
orientation: orientation,
|
||||||
|
paneInFirstChild: first.containsTarget,
|
||||||
|
dividerPosition: CGFloat(split.dividerPosition),
|
||||||
|
axisPixels: max(axisPixels, 1)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return V2PaneResizeTrace(containsTarget: containsTarget, bounds: combinedBounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func v2PaneResize(params: [String: Any]) -> V2CallResult {
|
private func v2PaneResize(params: [String: Any]) -> V2CallResult {
|
||||||
let direction = (v2String(params, "direction") ?? "").lowercased()
|
guard let tabManager = v2ResolveTabManager(params: params) else {
|
||||||
|
return .err(code: "unavailable", message: "TabManager not available", data: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
let directionRaw = (v2String(params, "direction") ?? "").lowercased()
|
||||||
let amount = v2Int(params, "amount") ?? 1
|
let amount = v2Int(params, "amount") ?? 1
|
||||||
guard ["left", "right", "up", "down"].contains(direction), amount > 0 else {
|
guard let direction = V2PaneResizeDirection(rawValue: directionRaw), amount > 0 else {
|
||||||
return .err(code: "invalid_params", message: "direction must be one of left|right|up|down and amount must be > 0", data: nil)
|
return .err(code: "invalid_params", message: "direction must be one of left|right|up|down and amount must be > 0", data: nil)
|
||||||
}
|
}
|
||||||
return .err(
|
|
||||||
code: "not_supported",
|
var result: V2CallResult = .err(code: "internal_error", message: "Failed to resize pane", data: nil)
|
||||||
message: "pane.resize is not supported yet; Bonsplit does not currently expose a stable programmable divider API",
|
v2MainSync {
|
||||||
data: [
|
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
||||||
"direction": direction,
|
result = .err(code: "not_found", message: "Workspace not found", data: nil)
|
||||||
"amount": amount
|
return
|
||||||
]
|
}
|
||||||
|
|
||||||
|
let paneUUID = v2UUID(params, "pane_id") ?? ws.bonsplitController.focusedPaneId?.id
|
||||||
|
guard let paneUUID else {
|
||||||
|
result = .err(code: "not_found", message: "No focused pane", data: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard ws.bonsplitController.allPaneIds.contains(where: { $0.id == paneUUID }) else {
|
||||||
|
result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let tree = ws.bonsplitController.treeSnapshot()
|
||||||
|
var candidates: [V2PaneResizeCandidate] = []
|
||||||
|
let trace = v2PaneResizeCollectCandidates(
|
||||||
|
node: tree,
|
||||||
|
targetPaneId: paneUUID.uuidString,
|
||||||
|
candidates: &candidates
|
||||||
)
|
)
|
||||||
|
guard trace.containsTarget else {
|
||||||
|
result = .err(code: "not_found", message: "Pane not found in split tree", data: ["pane_id": paneUUID.uuidString])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let orientationMatches = candidates.filter { $0.orientation == direction.splitOrientation }
|
||||||
|
guard !orientationMatches.isEmpty else {
|
||||||
|
result = .err(
|
||||||
|
code: "invalid_state",
|
||||||
|
message: "No \(direction.splitOrientation) split ancestor for pane",
|
||||||
|
data: ["pane_id": paneUUID.uuidString, "direction": direction.rawValue]
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let candidate = orientationMatches.first(where: { $0.paneInFirstChild == direction.requiresPaneInFirstChild }) else {
|
||||||
|
result = .err(
|
||||||
|
code: "invalid_state",
|
||||||
|
message: "Pane has no adjacent border in direction \(direction.rawValue)",
|
||||||
|
data: ["pane_id": paneUUID.uuidString, "direction": direction.rawValue]
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let delta = CGFloat(amount) / candidate.axisPixels
|
||||||
|
let requested = candidate.dividerPosition + (direction.dividerDeltaSign * delta)
|
||||||
|
let clamped = min(max(requested, 0.1), 0.9)
|
||||||
|
guard ws.bonsplitController.setDividerPosition(clamped, forSplit: candidate.splitId, fromExternal: true) else {
|
||||||
|
result = .err(
|
||||||
|
code: "internal_error",
|
||||||
|
message: "Failed to set split divider position",
|
||||||
|
data: ["split_id": candidate.splitId.uuidString]
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let windowId = v2ResolveWindowId(tabManager: tabManager)
|
||||||
|
result = .ok([
|
||||||
|
"window_id": v2OrNull(windowId?.uuidString),
|
||||||
|
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||||
|
"workspace_id": ws.id.uuidString,
|
||||||
|
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
||||||
|
"pane_id": paneUUID.uuidString,
|
||||||
|
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
|
||||||
|
"split_id": candidate.splitId.uuidString,
|
||||||
|
"direction": direction.rawValue,
|
||||||
|
"amount": amount,
|
||||||
|
"old_divider_position": candidate.dividerPosition,
|
||||||
|
"new_divider_position": clamped
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private func v2PaneSwap(params: [String: Any]) -> V2CallResult {
|
private func v2PaneSwap(params: [String: Any]) -> V2CallResult {
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,46 @@ def _surface_has(c: cmux, workspace_id: str, surface_id: str, token: str) -> boo
|
||||||
return token in str(payload.get("text") or "")
|
return token in str(payload.get("text") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _layout_panes(c: cmux) -> List[dict]:
|
||||||
|
layout_payload = c.layout_debug() or {}
|
||||||
|
layout = layout_payload.get("layout") or {}
|
||||||
|
panes = layout.get("panes") or []
|
||||||
|
return list(panes)
|
||||||
|
|
||||||
|
|
||||||
|
def _pane_extent(c: cmux, pane_id: str, axis: str) -> float:
|
||||||
|
panes = _layout_panes(c)
|
||||||
|
for pane in panes:
|
||||||
|
pid = str(pane.get("paneId") or pane.get("pane_id") or "")
|
||||||
|
if pid != pane_id:
|
||||||
|
continue
|
||||||
|
frame = pane.get("frame") or {}
|
||||||
|
return float(frame.get(axis) or 0.0)
|
||||||
|
raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}")
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_resize_target(c: cmux, pane_ids: List[str]) -> Tuple[str, str, str]:
|
||||||
|
panes = [p for p in _layout_panes(c) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids]
|
||||||
|
if len(panes) < 2:
|
||||||
|
raise cmuxError(f"Need >=2 panes for resize test, got {panes}")
|
||||||
|
|
||||||
|
def x_of(p: dict) -> float:
|
||||||
|
return float((p.get("frame") or {}).get("x") or 0.0)
|
||||||
|
|
||||||
|
def y_of(p: dict) -> float:
|
||||||
|
return float((p.get("frame") or {}).get("y") or 0.0)
|
||||||
|
|
||||||
|
x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes)
|
||||||
|
y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes)
|
||||||
|
|
||||||
|
if x_span >= y_span:
|
||||||
|
target = min(panes, key=x_of)
|
||||||
|
return str(target.get("paneId") or target.get("pane_id") or ""), "-R", "width"
|
||||||
|
|
||||||
|
target = min(panes, key=y_of)
|
||||||
|
return str(target.get("paneId") or target.get("pane_id") or ""), "-D", "height"
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
cli = _find_cli_binary()
|
cli = _find_cli_binary()
|
||||||
stamp = int(time.time() * 1000)
|
stamp = int(time.time() * 1000)
|
||||||
|
|
@ -206,8 +246,13 @@ def main() -> int:
|
||||||
merged = f"{proc.stdout}\n{proc.stderr}".lower()
|
merged = f"{proc.stdout}\n{proc.stderr}".lower()
|
||||||
_must(proc.returncode != 0 and "not supported" in merged, f"Expected not_supported for {cmd}, got: {merged!r}")
|
_must(proc.returncode != 0 and "not supported" in merged, f"Expected not_supported for {cmd}, got: {merged!r}")
|
||||||
|
|
||||||
resize = _run_cli(cli, ["resize-pane", "--pane", lp_source, "-L", "--amount", "5"], expect_ok=False)
|
resize_target, resize_flag, resize_axis = _pick_resize_target(c, current_panes)
|
||||||
_must(resize.returncode != 0, "Expected resize-pane to return not_supported until backend support is added")
|
pre_extent = _pane_extent(c, resize_target, resize_axis)
|
||||||
|
_run_cli(cli, ["resize-pane", "--pane", resize_target, resize_flag, "--amount", "80"])
|
||||||
|
_wait_for(
|
||||||
|
lambda: _pane_extent(c, resize_target, resize_axis) > pre_extent + 1.0,
|
||||||
|
timeout_s=3.0,
|
||||||
|
)
|
||||||
|
|
||||||
buffer_token = f"TMUX_BUFFER_{stamp}"
|
buffer_token = f"TMUX_BUFFER_{stamp}"
|
||||||
_run_cli(cli, ["set-buffer", "--name", "tmuxbuf", f"echo {buffer_token}\\n"])
|
_run_cli(cli, ["set-buffer", "--name", "tmuxbuf", f"echo {buffer_token}\\n"])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue