Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -237,15 +237,8 @@ enum PaneHandlers {
let resizes: [(ActionID, LayoutDirection)] = [("resizePaneLeft", .left), ("resizePaneRight", .right), ("resizePaneUp", .up), ("resizePaneDown", .down)]
for (id, direction) in resizes {
registry.bind(id, invoke: { invocation in
guard let pane = ctx.paneController(invocation), let content = pane.workspace,
let screen = content.layoutModel.screen(containing: pane.layoutPaneID) else { return }
switch PaneResize.change(for: pane.layoutPaneID, direction: direction, in: screen.layout,
shown: content.layoutView.shownResizeRange(ofScreenContaining: pane.layoutPaneID)) {
case .splitRatio(let split, let ratio):
content.layoutModel.setSplitRatio(split, ratio: ratio, transaction: .make(), phase: .ended)
case .columnWidth(let column, let width):
content.layoutModel.setColumnWidth(column, width: width, transaction: .make(), phase: .ended)
case nil:
guard let pane = ctx.paneController(invocation), let content = pane.workspace else { return }
if !PaneResize.press(pane.layoutPaneID, direction, model: content.layoutModel, view: content.layoutView) {
ctx.refuseQuietly(RefusalStrings.noDividerToMove(RefusalStrings.direction(direction)))
}
})
Expand Down
21 changes: 21 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextBridge/PaneResize.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ public nonisolated enum PaneResize {
public enum Change: Equatable, Sendable {
case splitRatio(LayoutSplitID, Double)
case columnWidth(LayoutColumnID, Double)
/// Every row height of a rows-v1 column (permille), one row edge moved.
case rowHeights(LayoutColumnID, [RowHeight])
}

/// Default keyboard step: 5% of the split or of the viewport width.
Expand All @@ -29,6 +31,10 @@ public nonisolated enum PaneResize {
return stepped(ratio, by: delta, within: SplitRatio.range, shown: shown?.ratio(of: split)).map { .splitRatio(split, $0) }
}
guard let column = layout.column(containing: pane) else { return nil }
// A pane in a row (rows-v1) with no vertical split of its own: up and down move its row's edge.
if axis == .vertical, column.hasRows, column.dock?.edge.isBand != true {
return shown?.rowHeights(moving: pane, in: column, by: delta).map { .rowHeights(column.id, $0) }
}
let range = ColumnWidthPreset.widthRange
// A top or bottom dock's extent is its height: up and down move its
// inner edge (down grows a top dock, up grows a bottom dock).
Expand All @@ -55,6 +61,21 @@ public nonisolated enum PaneResize {
return abs(next - current) < 1e-9 ? nil : next
}

/// One keyboard resize press on `pane` (`resizePane*`): the change ``change(for:direction:in:shown:step:)`` makes,
/// inside what `view` shows, written to `model`. False when nothing can move that way (the caller refuses
/// quietly); true for a pane on no screen (nothing to say).
@MainActor
public static func press(_ pane: LayoutPaneID, _ direction: LayoutDirection, model: LayoutModel, view: LayoutRootView) -> Bool {
guard let screen = model.screen(containing: pane) else { return true }
switch change(for: pane, direction: direction, in: screen.layout, shown: view.shownResizeRange(ofScreenContaining: pane)) {
case .splitRatio(let split, let ratio): model.setSplitRatio(split, ratio: ratio, transaction: .make(), phase: .ended)
case .columnWidth(let column, let width): model.setColumnWidth(column, width: width, transaction: .make(), phase: .ended)
case .rowHeights(let column, let heights): model.setRowHeights(column, heights: heights, fit: heights.reduce(0) { $0 + $1.height } == 1000)
case nil: return false
}
return true
}

/// The column before (`forward == false`) or after the pane's column.
public static func adjacentColumn(of pane: LayoutPaneID, forward: Bool, in layout: ScreenLayout) -> LayoutColumn? {
// Visual order: a docked column sits at its edge whatever its daemon index.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,54 @@ public nonisolated struct ShownResizeRange {
viewportWidth: geometry.stripWidth, gap: style.stripGap)
return lower...max(lower, ColumnWidthPreset.widthRange.upperBound)
}

/// Every row height of `column` after a keyboard press moves the row edge next to `pane`'s row by `delta`
/// (a share of the column's height; down is positive): the edge below that row, or above it for the last row.
/// The press trades whole permille between the two rows (or, in a scrolling stack, sizes the upper row alone),
/// the exact inverse of ``RowStackGeometry/compute``'s placement, so each press moves the edge by the same
/// `(height + gap) * delta` points, snapped once to the pixel grid. Each row keeps 100 permille and the height its
/// tree needs; a press at that limit is nil (nothing moves).
public func rowHeights(moving pane: PaneID, in column: LayoutColumn, by delta: Double) -> [RowHeight]? {
guard column.hasRows, let stack = geometry.rowStacks[column.id],
let index = column.rows.firstIndex(where: { $0.root.contains(pane) }) else { return nil }
let upper = index + 1 < column.rows.count ? index : index - 1
guard upper >= 0 else { return nil }
let rows = column.rows, sum = rows.reduce(0) { $0 + max($1.height, 1) }
let fills = !style.rowsEnabled || sum <= 1000
// Permille as placed: a filled stack scales every row by 1000 / sum.
let scale = fills && sum > 0 ? Double(sum) / 1000 : 1
func floor(_ row: LayoutRow) -> Int {
let points = SplitGeometry.minimumSize(of: row.root, style: style).height
let placed = Double(RowStackGeometry.permille(forPixelHeight: points, columnHeight: stack.frame.height, gap: stack.gap))
return max(LayoutRow.heightRange.lowerBound, Int((placed * scale).rounded(.up)))
}
let step = Int((delta * 1000 * scale).rounded())
guard step != 0 else { return nil }
var heights = rows.map { RowHeight(row: $0.id, height: $0.height) }
let range = LayoutRow.heightRange
// The upper row's permille where it shows: a row stored below what its panes need shows at that minimum.
let shownUpper = max(rows[upper].height, floor(rows[upper]))
/// From where the row shows, one step toward `next`, inside `low...high`; nil when nothing would move, so a
/// press never moves the edge the wrong way or sends a height the model refuses.
func stepped(low: Int, high: Int) -> Int? {
guard low <= high else { return nil }
let next = step > 0 ? min(shownUpper + step, high) : max(shownUpper + step, low)
return (step > 0 ? next > shownUpper : next < shownUpper) ? next : nil
}
if fills {
let pair = rows[upper].height + rows[upper + 1].height
// Both rows stay in 100...1000 (setRowHeights refuses others) and keep their minimums.
guard let next = stepped(low: max(floor(rows[upper]), pair - range.upperBound),
high: min(pair - floor(rows[upper + 1]), range.upperBound)) else { return nil }
heights[upper].height = next
heights[upper + 1].height = pair - next
} else {
// A scrolling stack: the upper row alone, and never below the sum that fills the column (there the stack
// places rows in proportion, a different edge): the press that reaches it is a part step.
let rest = sum - rows[upper].height
guard let next = stepped(low: max(floor(rows[upper]), 1000 - rest), high: range.upperBound) else { return nil }
heights[upper].height = next
}
return heights
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import CmuxNextBridge
import CoreGraphics
@testable import CmuxNextLayout
import Testing

/// RESIZE KEYS K4, the review of #32471 (FIX FIRST at d5d5c04fcf0): row heights the screen does not show as stored.
/// - A row stored below the height its panes need shows at that minimum: a press must step from what shows, never move
/// the edge the wrong way or change the model with no motion (the review saw Down move an edge 168 pt UP).
/// - Rows off (`fits`) with heights summing above 1000 (New Row on a full-height row gives [1000, 1000]): a press must
/// never send heights outside 100...1000 (the model drops them and the key does nothing, with no refusal).
/// - A scrolling stack (sum above 1000) that a press brings to 1000 or less fills the column: no press may jump more than
/// one step there.
/// - A top or bottom dock with rows: up and down keep resizing the dock band, as before rows.
struct KeyboardResizeRowsEdgeCasesTests {
static let viewport = CGSize(width: 1153, height: 857)

static func style(rows: Bool = true) -> LayoutStyle {
var style = LayoutStyle()
style.minimumPaneContentSize = CGSize(width: 200, height: 64)
style.rowsEnabled = rows
return style
}

/// Panes split vertically one under the other: a row whose panes need one minimum height each.
static func stack(_ panes: [PaneID]) -> SplitNode {
panes.dropLast().reversed().reduce(SplitNode.leaf(panes[panes.count - 1])) { tail, pane in
.split(SplitID(rawValue: "s-\(pane.rawValue)"), axis: .vertical, ratio: 0.5, a: .leaf(pane), b: tail)
}
}

static func layout(_ heights: [Int], rows: [SplitNode], dock: DockColumn? = nil) -> ScreenLayout {
let ids = (0..<heights.count).map { RowID(rawValue: "r\($0)") }
let compat = rows.dropLast().enumerated().reversed().reduce(rows[rows.count - 1]) { tail, item in
.split(SplitID(rawValue: "compat-\(item.offset)"), axis: .vertical, ratio: 0.5, a: item.element, b: tail)
}
return .columns([LayoutColumn(id: "c0", width: 1, root: compat, dock: dock,
rows: zip(ids, zip(heights, rows)).map { LayoutRow(id: $0, height: $1.0, root: $1.1) })])
}

struct Press { var change: PaneResize.Change?; var before: CGFloat; var after: CGFloat }

/// One press: the change, and the shown edge below the first row before and after it.
static func press(_ direction: LayoutDirection, on pane: PaneID, _ layout: ScreenLayout, style: LayoutStyle) -> (Press, ScreenLayout) {
let geometry = ScreenGeometry.compute(layout, viewport: viewport, style: style)
let edge = { (g: ScreenGeometry) in g.rowStacks["c0"]?.rows.first?.frame.maxY ?? -1 }
let change = PaneResize.change(for: pane, direction: direction, in: layout, shown: ShownResizeRange(geometry: geometry, style: style))
var next = layout
if case .rowHeights(let column, let heights)? = change { next = layout.settingRowHeights(heights, for: column) }
return (Press(change: change, before: edge(geometry), after: edge(ScreenGeometry.compute(next, viewport: viewport, style: style))), next)
}

static func step(_ style: LayoutStyle, _ layout: ScreenLayout) -> CGFloat {
let stack = ScreenGeometry.compute(layout, viewport: viewport, style: style).rowStacks["c0"]!
return (stack.frame.height + stack.gap) * CGFloat(PaneResize.step)
}

@Test func anUpperRowStoredBelowItsMinimumStepsFromWhereItShows() {
let style = Self.style()
// p1 beside a stack of three: the row needs three panes' height, and p1 has no vertical split of its own (a split
// in the pane's row wins over the row edge).
let upperRow = SplitNode.split("h", axis: .horizontal, ratio: 0.5, a: .leaf("p1"), b: Self.stack(["q1", "q2", "q3"]))
let start = Self.layout([100, 900], rows: [upperRow, .leaf("p2")])
let (up, _) = Self.press(.up, on: "p1", start, style: style)
#expect(up.change == nil, "Up on a row at its minimum changed the model: \(String(describing: up.change))")
let (down, _) = Self.press(.down, on: "p1", start, style: style)
#expect(abs((down.after - down.before) - Self.step(style, start)) <= 1, "Down moved \(down.after - down.before), want one step")
}

@Test func aLowerRowBelowItsMinimumNeverMovesTheEdgeTheWrongWay() {
let style = Self.style()
let start = Self.layout([600, 400], rows: [.leaf("p1"), Self.stack(["p2", "q1", "q2", "q3", "q4", "q5"])])
let (down, _) = Self.press(.down, on: "p1", start, style: style)
#expect(down.change == nil || down.after - down.before > 0, "Down moved the edge up by \(down.before - down.after) pt")
#expect(down.change == nil || down.after != down.before, "Down changed the model and moved nothing")
let (up, _) = Self.press(.up, on: "p1", start, style: style)
#expect(abs((up.after - up.before) + Self.step(style, start)) <= 1, "Up moved \(up.after - up.before), want minus one step")
}

@Test func rowsOffNeverSendsHeightsTheModelRefuses() {
let style = Self.style(rows: false)
let start = Self.layout([1000, 1000], rows: [.leaf("p1"), .leaf("p2")])
for direction in [LayoutDirection.up, .down] {
for pane: PaneID in ["p1", "p2"] {
let (press, _) = Self.press(direction, on: pane, start, style: style)
if case .rowHeights(_, let heights)? = press.change {
#expect(heights.allSatisfy { LayoutRow.heightRange.contains($0.height) }, "\(pane) \(direction): \(heights.map(\.height))")
}
}
}
}

@Test func aScrollingStackNeverJumpsMoreThanOneStep() {
let style = Self.style()
var layout = Self.layout([700, 700], rows: [.leaf("p1"), .leaf("p2")])
let step = Self.step(style, layout)
for _ in 0..<12 {
let (press, next) = Self.press(.up, on: "p1", layout, style: style)
guard press.change != nil else { break }
#expect(press.after - press.before < 0 && press.before - press.after <= step + 1,
"Up moved \(press.after - press.before), want at most one step up")
layout = next
}
}

@Test func aBandDockWithRowsStillResizesTheDock() {
let style = Self.style()
let dock = DockColumn(edge: .top, mode: .docked, role: nil, isPinned: false)
// Below its widest extent (1.0), so Down can grow it.
let start = ScreenLayout.columns([LayoutColumn(id: "c0", width: 0.4, root: .split("compat", axis: .vertical, ratio: 0.5, a: .leaf("p1"), b: .leaf("p2")),
dock: dock, rows: [LayoutRow(id: "r0", height: 500, root: .leaf("p1")),
LayoutRow(id: "r1", height: 500, root: .leaf("p2"))])])
let geometry = ScreenGeometry.compute(start, viewport: Self.viewport, style: style)
let change = PaneResize.change(for: "p1", direction: .down, in: start, shown: ShownResizeRange(geometry: geometry, style: style))
if case .columnWidth? = change {} else { Issue.record("Down on a top dock with rows made \(String(describing: change)), want the dock's extent") }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import CmuxNextBridge
import CoreGraphics
@testable import CmuxNextLayout
import Testing

/// RESIZE KEYS K4 (cmux-ad 2026-11-02, P0 "pixel perfect ctrl shift hjkl"; film rk-rows on feat 507438d and the dogfood
/// source): in a column of two rows (rows-v1, New Row), Ctrl-Shift-J/K and Ctrl-Cmd-Down/Up on the upper row moved
/// nothing. `PaneResize.change` looked for a vertical split in the row's own tree and then for a column dock band, and
/// found neither: a row edge was not a key resize target. A press must move the edge next to the pane's row one whole
/// step (5 % of the column height), or nothing at a row's minimum.
struct KeyboardResizeRowsTests {
static let viewport = CGSize(width: 1153, height: 857)

static var style: LayoutStyle {
var style = LayoutStyle()
style.minimumPaneContentSize = CGSize(width: 200, height: 64)
style.rowsEnabled = true
return style
}

static let start = ScreenLayout.columns([
LayoutColumn(id: "c0", width: 1, root: .split("compat", axis: .vertical, ratio: 0.5, a: .leaf("p1"), b: .leaf("p2")),
rows: [LayoutRow(id: "r1", height: 500, root: .leaf("p1")), LayoutRow(id: "r2", height: 500, root: .leaf("p2"))]),
])

/// The row edge's y after each press of `direction` on `pane` (nil: the press changed nothing).
static func edges(_ direction: LayoutDirection, _ count: Int, on pane: PaneID) -> [CGFloat?] {
var layout = start, out: [CGFloat?] = []
for _ in 0..<count {
let geometry = ScreenGeometry.compute(layout, viewport: viewport, style: style)
switch PaneResize.change(for: pane, direction: direction, in: layout, shown: ShownResizeRange(geometry: geometry, style: style)) {
case .rowHeights(let column, let heights)?:
layout = layout.settingRowHeights(heights, for: column)
out.append(ScreenGeometry.compute(layout, viewport: viewport, style: style).rowStacks["c0"]?.rows.first?.frame.maxY)
case nil: out.append(nil)
default: Issue.record("a row press made another change"); out.append(nil)
}
}
return out
}

@Test func downAndUpMoveTheRowEdgeOneWholeStepFromEitherRow() throws {
let stack = try #require(ScreenGeometry.compute(Self.start, viewport: Self.viewport, style: Self.style).rowStacks["c0"])
let base = try #require(stack.rows.first?.frame.maxY)
let step = (stack.frame.height + stack.gap) * CGFloat(PaneResize.step)
for (pane, direction, sign) in [("p1", LayoutDirection.down, 1.0), ("p1", .up, -1.0), ("p2", .down, 1.0), ("p2", .up, -1.0)] as [(PaneID, LayoutDirection, CGFloat)] {
let moved = Self.edges(direction, 3, on: pane)
var last = base
for edge in moved {
let edge = try #require(edge, "\(pane) \(direction): a press moved nothing (\(moved))")
#expect(abs((edge - last) - sign * step) <= 1, "\(pane) \(direction): step \(edge - last), want \(sign * step)")
last = edge
}
}
}

@Test func aPressAtARowMinimumChangesNothing() {
let moved = Self.edges(.up, 30, on: "p1")
let firstStop = moved.firstIndex(of: nil)
#expect(firstStop != nil, "the edge never stopped: \(moved)")
if let firstStop { #expect(moved[firstStop...].allSatisfy { $0 == nil }, "a press after the minimum moved: \(moved)") }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ struct KeyboardResizeShownLimitTests {
switch PaneResize.change(for: pane, direction: direction, in: layout, shown: shown) {
case .splitRatio(let split, let ratio): return layout.settingRatio(ratio, for: split)
case .columnWidth(let column, let width): return layout.settingWidth(width, for: column)
case .rowHeights(let column, let heights): return layout.settingRowHeights(heights, for: column)
case nil: return nil
}
}
Expand Down
Loading