Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
68b7d12
test(next panes): resize keys move a strip column's divider in the ar…
lawrencecchen Nov 3, 2026
ee942a6
fix(next panes): resize keys move a strip column's divider in the arr…
lawrencecchen Nov 3, 2026
3f079dc
test(next panes): build the window-edge resize case explicitly
lawrencecchen Nov 3, 2026
c7fa116
test(next panes): a trade from a column shown at its minimum moves no…
lawrencecchen Nov 3, 2026
ce81356
fix(next panes): a key trade starts from the width a column shows; a …
lawrencecchen Nov 3, 2026
2b6dc4d
test(next panes): key trades on odd-pixel strips move only the divide…
lawrencecchen Nov 3, 2026
7dc25a2
fix(next panes): a key trade keeps the pair's ideal sum and checks it…
lawrencecchen Nov 3, 2026
dd6fdec
Merge remote-tracking branch 'origin/feat-cmux-next' into fix/frame-o…
lawrencecchen Nov 3, 2026
d0a50f6
test(next panes): a key trade is all or nothing: a refused width send…
lawrencecchen Nov 3, 2026
035fa46
fix(next panes): a key press's or a drag release's values are one cha…
lawrencecchen Nov 3, 2026
46efe54
test(next panes): a trade answered and applied in its last width's tu…
lawrencecchen Nov 3, 2026
bf4dfe9
fix(next panes): EndedValues notes a refusal where it happens, not fr…
lawrencecchen Nov 3, 2026
6f32972
test(next panes): a change sent from inside another keeps the outer o…
lawrencecchen Nov 3, 2026
3eefb96
test(next panes): a transaction keeps its daemon number when another …
lawrencecchen Nov 3, 2026
d307c16
fix(next panes): a change keeps its refusal and its daemon number whe…
lawrencecchen Nov 3, 2026
a448d7f
test(next panes): a nested send of the same transaction keeps its ref…
lawrencecchen Nov 3, 2026
c4b8d66
fix(next panes): nested sends keep a refusal scope each; a transactio…
lawrencecchen Nov 3, 2026
c4aaf3d
Merge remote-tracking branch 'origin/feat-cmux-next' into fix/frame-o…
lawrencecchen Nov 3, 2026
aae3728
Merge remote-tracking branch 'origin/feat-cmux-next' into fix/frame-o…
lawrencecchen Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ extension WorkspaceContentController {
focus.send(.focusPane(pane.rawValue, source: .intent))
case .setSplitRatio(let split, let ratio, let transaction, _):
guard let handle = handles.splits[split] else { return layoutModel.rejectTransaction(transaction) }
let daemonTransaction = nextDaemonTransaction()
let daemonTransaction = gestureTransactions.number(for: transaction)
sendGesture(transaction, label: "set-split-ratio") { connection in
try await connection.setSplitRatio(handle, ratio: ratio, transaction: daemonTransaction)
}
case .setColumnWidth(_, let anyPane, let width, let transaction, _):
guard let handle = handles.panes[anyPane] else { return layoutModel.rejectTransaction(transaction) }
let daemonTransaction = nextDaemonTransaction()
let daemonTransaction = gestureTransactions.number(for: transaction)
sendGesture(transaction, label: "set-viewport-pane-width") { connection in
try await connection.setColumnWidth(of: handle, width: width, transaction: daemonTransaction)
}
Expand All @@ -38,7 +38,7 @@ extension WorkspaceContentController {
if dock?.edge.isBand == true, !daemon.supports(DaemonCapability.edgeDocks.rawValue) {
return layoutModel.rejectTransaction(transaction)
}
let daemonTransaction = nextDaemonTransaction()
let daemonTransaction = gestureTransactions.number(for: transaction)
let wire = dock.map(LayoutMapping.snapshot)
sendGesture(transaction, label: "set-column-dock") { connection in
try await connection.setColumnDock(of: handle, dock: wire, transaction: daemonTransaction)
Expand Down Expand Up @@ -200,19 +200,12 @@ extension WorkspaceContentController {
}
}

private func nextDaemonTransaction() -> UInt64 {
nextGestureTransaction += 1
return nextGestureTransaction
}

/// Sends a gesture's one command; its result settles or rolls back the layout's override.
private func sendGesture(_ transaction: LayoutTransactionID, label: String, _ body: @escaping DaemonCommandBody) {
Task { [daemon, layoutModel] in
if await daemon.run(label, body) {
layoutModel.settleTransaction(transaction)
} else {
layoutModel.rejectTransaction(transaction)
}
Task { [weak self, daemon, layoutModel] in
let ok = await daemon.run(label, body)
self?.gestureTransactions.answered(transaction)
ok ? layoutModel.settleTransaction(transaction) : layoutModel.rejectTransaction(transaction)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ final class WorkspaceContentController: LayoutPaneContentProvider {
/// plans/cmux-next/focus.md). Every focus change in this content goes
/// through it.
let focus: FocusCoordinator
var nextGestureTransaction: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000) << 8
var gestureTransactions = GestureTransactionNumbers()
/// The last key-turn pane create (Cmd-D's split, New Column, New Row) still sending: the next one sends after
/// it, so one pressed on its stand-in names the daemon's pane (`createInKeysTurn`, `PaneHandlers.split`).
var pendingCreate: ActionWork?
Expand Down Expand Up @@ -140,6 +140,7 @@ final class WorkspaceContentController: LayoutPaneContentProvider {
self.state = state
focus = state.focus
layoutModel.intentHandler = { [weak self] intent in self?.handle(intent) }
layoutModel.sendsNow = { [weak self] intent in self?.handles.sendsNow(intent) ?? false }
_ = layoutView // built here, then the bar and the content view, as before
// A scroll the layout took is timed as the layout's (`key.scroll_frame target=layout`).
layoutView.onScrollTaken = { [inputDispatch = services.inputDispatch] in inputDispatch.noteLayoutTookScroll() }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
public import CmuxNextLayout
import Foundation

/// The daemon `transaction` numbers of one workspace's layout gestures. A layout transaction is one gesture (a drag's
/// release, a key press, a split's sizing): every value of it goes out under one number, so the daemon keeps one undo
/// entry for all its widths and ratios (`layout_undo_coalesces_every_target_in_one_resize_transaction` in
/// cmux-tui-core) and Undo Layout Change takes the gesture back in one step. (The daemon keeps a dock change apart
/// from resizes, so a dock pin with a width is still two steps.) A transaction keeps its number while any of its
/// commands is in flight: its values go out in one turn and are answered in later ones, so every value of it, whatever
/// other gestures go out between them, takes the same number, and nothing of it is kept once its last command is answered.
public struct GestureTransactionNumbers {
/// The number the newest layout transaction took; the next one takes the number after it.
public private(set) var latest = UInt64(Date().timeIntervalSince1970 * 1000) << 8
private var open: [LayoutTransactionID: (number: UInt64, inFlight: Int)] = [:]

public init() {}

/// The daemon number for one more command of `transaction`: the one its commands in flight have, or the next one.
public mutating func number(for transaction: LayoutTransactionID) -> UInt64 {
if let entry = open[transaction] {
open[transaction] = (entry.number, entry.inFlight + 1)
return entry.number
}
latest += 1
open[transaction] = (latest, 1)
return latest
}

/// A command of `transaction` was answered, or failed.
public mutating func answered(_ transaction: LayoutTransactionID) {
guard let entry = open[transaction] else { return }
open[transaction] = entry.inFlight > 1 ? (entry.number, entry.inFlight - 1) : nil
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ public struct LayoutHandleMap: Sendable, Equatable {

public init() {}

/// Whether `intent`'s command can go to the daemon now (`LayoutModel.sendsNow`): its split has a handle, its column's
/// pane a handle of a pane the daemon made (a stand-in's names nothing there).
public func sendsNow(_ intent: LayoutIntent) -> Bool {
switch intent {
case let .setSplitRatio(split, _, _, _): splits[split] != nil
case let .setColumnWidth(_, anyPane, _, _, _): panes[anyPane].map { !ProvisionalPane.isStandIn($0) } ?? false
default: true
}
}

mutating func addPane(_ id: LayoutPaneID, handle: DaemonPaneID) {
panes[id] = handle
paneIDs[handle] = id
Expand Down
71 changes: 56 additions & 15 deletions Packages/macOS/CmuxNext/Sources/CmuxNextBridge/PaneResize.swift
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
public import CmuxNextLayout

/// Keyboard pane resizing and column navigation over one screen's layout.
/// Resizing moves the divider nearest the pane in the arrow's direction;
/// in columns mode a left or right
/// resize with no horizontal split in the column changes the column width.
/// A press steps from the size the screen shows and stops at the size it can show (`shown`): one equal step, the
/// part step that reaches a pane's minimum, or no change at all; never a model change that moves nothing.
///
/// One press moves one divider one step in the arrow's direction, as Ghostty's `resize_split` does, and changes only
/// the two sides of that divider (FRAME OWNER REFACTOR KEYS, lead decision 2026-11-03):
/// - A split: the deepest split along the arrow's axis that holds the pane; its ratio moves toward the arrow.
/// - A strip column (columns mode, no split along the axis): the divider is the column's right edge when a strip
/// column follows it, else its left edge (the last column). The two columns beside it trade width (an Option-drag's
/// trade): the columns after them and the strip's camera stay. One column alone moves its right edge.
/// - A side dock: its inner edge (a left dock grows with Right, a right dock shrinks with Right). A top or bottom
/// dock: its inner edge with Up and Down. A row (rows-v1): the row edge with Up and Down.
/// A press steps from the size the screen shows and stops at the size it can show (`shown`): one equal step, the part
/// step that reaches a pane's minimum or the edge of the strip the window shows, or no change at all; never a model
/// change that moves nothing.
public nonisolated enum PaneResize {
public enum Change: Equatable, Sendable {
case splitRatio(LayoutSplitID, Double)
case columnWidth(LayoutColumnID, Double)
/// Two neighbor strip columns, left then right, trade width across the divider between them.
case columnTrade(LayoutColumnID, Double, LayoutColumnID, Double)
/// Every row height of a rows-v1 column (permille), one row edge moved.
case rowHeights(LayoutColumnID, [RowHeight])
}
Expand Down Expand Up @@ -45,7 +54,22 @@ public nonisolated enum PaneResize {
return extent == column.width ? nil : .columnWidth(column.id, extent)
}
guard axis == .horizontal else { return nil }
return stepped(column.width, by: delta, within: range, shown: shown?.width(of: column)).map { .columnWidth(column.id, $0) }
let parts = DockStripGeometry.docks(layout.columns)
// A side dock's inner edge: a left dock's right edge, a right dock's left edge, inside the widths it shows.
if parts.left?.id == column.id || parts.right?.id == column.id {
let grow = parts.right?.id == column.id ? -delta : delta
return stepped(column.width, by: grow, within: range, shown: shown?.dockWidth(of: column)).map { .columnWidth(column.id, $0) }
}
let strip = parts.scrolling
guard let index = strip.firstIndex(where: { $0.id == column.id }) else { return nil }
guard strip.count > 1 else {
// A screen whose only column is a dock shows it full width whatever its width (`DockStripGeometry.docks`): no
// press moves it.
guard strip[index].dock == nil else { return nil }
return stepped(column.width, by: delta, within: range, shown: shown?.width(of: column)).map { .columnWidth(column.id, $0) }
}
let (left, right) = index + 1 < strip.count ? (strip[index], strip[index + 1]) : (strip[index - 1], strip[index])
return shown?.trade(left, right, by: delta, in: layout).map { .columnTrade(left.id, $0.left, right.id, $0.right) }
}

/// `value` moved by `delta` inside `range` cut to `shown`, from where the screen shows it: a value past what the
Expand All @@ -67,19 +91,36 @@ public nonisolated enum PaneResize {
@MainActor
public static func press(_ pane: LayoutPaneID, _ direction: LayoutDirection, model: LayoutModel, view: LayoutRootView) -> Bool {
guard let screen = model.screen(containing: pane) else { return true }
let change = change(for: pane, direction: direction, in: screen.layout, shown: view.shownResizeRange(ofScreenContaining: pane))
// Every pane of the screen keeps its content's size through the spring and lays it out once at the landing.
if change != nil { view.keyboardResizeWillChange(screenContaining: pane) }
switch change {
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
guard let change = change(for: pane, direction: direction, in: screen.layout, shown: view.shownResizeRange(ofScreenContaining: pane)) else {
return false
}
// A split ratio, a width or a trade is one change, all or nothing (`EndedValues`): refused before anything moves
// when the app cannot send all of it now (a column the daemon has not made yet).
let values: EndedValues? = switch change {
case .splitRatio(let split, let ratio): EndedValues([.ratio(split, ratio)])
case .columnWidth(let column, let width): EndedValues([.width(column, width)])
case .columnTrade(let left, let leftWidth, let right, let rightWidth): EndedValues([.width(left, leftWidth), .width(right, rightWidth)])
case .rowHeights: nil
}
if let values, !values.canSend(in: model) { return false }
// Every pane of the screen keeps its content's size through the spring and lays it out once at the landing; a
// trade keeps its left column (and the camera) in place, so only the divider between them moves.
if case .columnTrade(let left, _, _, _) = change {
view.keyboardResizeWillChange(screenContaining: pane, keeping: left)
} else {
view.keyboardResizeWillChange(screenContaining: pane)
}
var sent = true
if let values {
sent = values.send(in: model)
} else if case .rowHeights(let column, let heights) = change {
model.setRowHeights(column, heights: heights, fit: heights.reduce(0) { $0 + $1.height } == 1000)
}
// The spring starts in the key's own turn (as a split's frame does, `LayoutRootView.syncWithModel`): the very
// next display frame moves, never one that ran before the layout's observation turn.
view.syncWithModel()
return true
view.keyboardResizeDidChange(screenContaining: pane)
return sent
}

/// The column before (`forward == false`) or after the pane's column.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ public struct ProvisionalPane: Sendable, Hashable {
private static let handleBase: UInt64 = 1 << 62
/// The lowest stand-in handle: a pane handle at or above it is a stand-in.
static let firstHandle: UInt64 = handleBase

/// Whether `handle` is a stand-in's: a pane the daemon has not made, whose commands it refuses (`PaneNotReady`).
public static func isStandIn(_ handle: PaneID) -> Bool { handle.rawValue >= firstHandle }
@MainActor private static var next: UInt64 = 0

/// The provisional pane's handle in the store.
Expand Down
Loading
Loading