Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9512791
test(hover): the hover rules' Rust test corpus, ported from the Swift…
lawrencecchen Nov 2, 2026
b581621
feat(hover): the hover card machine, tooltip intent, safe triangle, r…
lawrencecchen Nov 2, 2026
93b19b7
refactor(hover): Swift calls the Rust hover rules; the Swift reducers…
lawrencecchen Nov 2, 2026
fe72ef2
refactor(hover): the new nested wire and snapshot types are nonisolat…
lawrencecchen Nov 2, 2026
95443c5
fix(hover): review findings: no unapplied mutating method in HoverInt…
lawrencecchen Nov 2, 2026
c4776fd
test(hover): the group summary compares values by canonical equivalen…
lawrencecchen Nov 2, 2026
4ead4a4
fix(hover): the group summary keeps Swift's String and Character rule…
lawrencecchen Nov 2, 2026
f2490c1
Merge remote-tracking branch 'origin/feat-cmux-next' into rust-migrat…
lawrencecchen Nov 2, 2026
4b073d1
refactor(hover): the C glue and the JSON wire are their own declarati…
lawrencecchen Nov 2, 2026
2b26b20
Merge branch 'feat-cmux-next' into rust-migration/hover
cmux-bot[bot] Nov 2, 2026
994f89d
fix(hover): the duration cast's allow names its reason (as on step A,…
lawrencecchen Nov 2, 2026
4f03328
refactor(hover): the suppression bits are literals (as on step A, #31…
lawrencecchen Nov 2, 2026
f7d0d97
Merge remote-tracking branch 'origin/feat-cmux-next' into rust-migrat…
lawrencecchen Nov 2, 2026
06911b5
test(hover): HoverCardSweepBench, main-thread ns per pointer move of …
lawrencecchen Nov 2, 2026
8a752f7
Merge branch 'feat-cmux-next' into rust-migration/hover
cmux-bot[bot] Nov 2, 2026
56a6e6b
Merge branch 'feat-cmux-next' into rust-migration/hover
cmux-bot[bot] Nov 2, 2026
18b6e2c
Merge branch 'feat-cmux-next' into rust-migration/hover
cmux-bot[bot] 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 @@ -2,7 +2,10 @@ public import Foundation
public import CmuxNextDesignTokens

/// What a group's hover card says: its title and one block of lines per shown section, in the
/// order `GroupHoverCardOptions` picks. Pure: the sidebar's group headers and the tab strip's
/// order `GroupHoverCardOptions` picks. The rule (which sections show, the counts, the latest
/// member, the branch and port lists) is cmux-layout-reducer's (`group_hover_summary`, through
/// `cmux_layout_reducer_json`, once per card show); this type puts its typed lines into the
/// localized words (`GroupHoverCardStrings`). The sidebar's group headers and the tab strip's
/// group chips build it from their members, and `GroupHoverCardView` draws it.
public nonisolated struct GroupHoverCardSummary: Sendable, Hashable {
/// The kind of group, for the member count's noun.
Expand All @@ -12,7 +15,7 @@ public nonisolated struct GroupHoverCardSummary: Sendable, Hashable {

/// The status line's color, mapped to a `HoverCardTone` where the card draws it (that type is
/// main-actor isolated; this summary is built anywhere).
public enum Tone: Sendable, Hashable {
public enum Tone: String, Sendable, Hashable, Decodable {
case secondary, working, attention, danger
}

Expand All @@ -39,65 +42,14 @@ public nonisolated struct GroupHoverCardSummary: Sendable, Hashable {
/// - now: The time ages are measured from.
public static func make(title: String, kind: Kind, members: [GroupHoverCardMember],
options: GroupHoverCardOptions, now: Date) -> Self {
var blocks: [Block] = []
for section in options.sections {
switch section {
case .status:
blocks.append(status(members))
case .members:
let shown = members.prefix(max(1, options.maxMembers)).map(\.title)
var lines = [GroupHoverCardStrings.count(kind, members.count)] + shown.map { "\u{2022} " + $0 }
if members.count > shown.count { lines.append(GroupHoverCardStrings.more(members.count - shown.count)) }
blocks.append(Block(section: .members, lines: lines, tone: .secondary))
case .activity:
let latest = members.compactMap { member in member.lastActivity.map { (member, $0) } }.max { $0.1 < $1.1 }
if let (member, date) = latest {
blocks.append(Block(section: .activity, lines: [GroupHoverCardStrings.latest(member.title, age: age(since: date, now: now))],
tone: .secondary))
}
case .git:
let branches = unique(members.compactMap(\.branch))
if !branches.isEmpty {
blocks.append(Block(section: .git, lines: [GroupHoverCardStrings.branches(joined(branches))], tone: .secondary))
}
case .ports:
let ports = unique(members.compactMap(\.ports).flatMap { $0.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } })
if !ports.isEmpty {
blocks.append(Block(section: .ports, lines: [GroupHoverCardStrings.ports(joined(ports))], tone: .secondary))
}
}
}
let request = GroupHoverCardSummaryWire.Request(
members: members.map(GroupHoverCardSummaryWire.Member.init), sections: options.sections.map(\.rawValue),
max_members: options.maxMembers, now: now.timeIntervalSinceReferenceDate)
// The rule cannot refuse a request this type builds; an empty card is the safe answer if it did.
let blocks = (GroupHoverCardSummaryWire.run(request)?.blocks ?? []).compactMap { block in block.words(kind) }
return Self(title: title, blocks: blocks)
}

/// "Working: 2 · Waiting: 1 · Unread: 3", or "All idle".
private static func status(_ members: [GroupHoverCardMember]) -> Block {
let working = members.count { $0.activity == .working }
let waiting = members.count { $0.activity == .waiting }
let failed = members.count { $0.activity == .failed }
let unread = members.reduce(0) { $0 + max(0, $1.unread) }
var parts: [String] = []
if working > 0 { parts.append(GroupHoverCardStrings.working(working)) }
if waiting > 0 { parts.append(GroupHoverCardStrings.waiting(waiting)) }
if failed > 0 { parts.append(GroupHoverCardStrings.failed(failed)) }
if unread > 0 { parts.append(GroupHoverCardStrings.unread(unread)) }
let tone: Tone = failed > 0 ? .danger : waiting > 0 ? .attention : working > 0 ? .working : .secondary
return Block(section: .status, lines: [parts.isEmpty ? GroupHoverCardStrings.allIdle : parts.joined(separator: " \u{00B7} ")], tone: tone)
}

/// The most values one line names before "+N".
static let maxListed = 4

private static func joined(_ values: [String]) -> String {
let shown = values.prefix(maxListed).joined(separator: ", ")
return values.count > maxListed ? shown + " " + GroupHoverCardStrings.more(values.count - maxListed) : shown
}

private static func unique(_ values: [String]) -> [String] {
var seen = Set<String>()
return values.filter { !$0.isEmpty && seen.insert($0).inserted }
}

private static let ageFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .abbreviated
Expand All @@ -106,10 +58,8 @@ public nonisolated struct GroupHoverCardSummary: Sendable, Hashable {
return formatter
}()

/// "12m", "5h"; "Just now" under a minute.
static func age(since date: Date, now: Date) -> String {
let seconds = now.timeIntervalSince(date)
guard seconds >= 60 else { return GroupHoverCardStrings.justNow }
return ageFormatter.string(from: seconds) ?? GroupHoverCardStrings.justNow
/// "12m", "5h"; "Just now" when the rule says so (under a minute).
static func age(seconds: Double, justNow: Bool) -> String {
justNow ? GroupHoverCardStrings.justNow : ageFormatter.string(from: seconds) ?? GroupHoverCardStrings.justNow
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import CCmuxLayoutReducerFFI
import Foundation
import CmuxNextDesignTokens

/// The JSON of `group_hover_summary` (cmux-tui/crates/cmux-layout-reducer/src/group_hover_summary.rs) and
/// the call through `cmux_layout_reducer_json`. No rule lives here; `GroupHoverCardSummary` puts the
/// typed lines into words.
nonisolated struct GroupHoverCardSummaryWire {
struct Member: Encodable {
var title: String
var activity: String
var unread: Int
var last_activity: Double?
var branch: String?
var ports: String?

init(_ member: GroupHoverCardMember) {
title = member.title
activity = switch member.activity {
case .idle: "idle"
case .working: "working"
case .waiting: "waiting"
case .failed: "failed"
}
unread = member.unread
// Foundation's reference date, as `now`: the rule's age is then `now.timeIntervalSince(date)`.
last_activity = member.lastActivity?.timeIntervalSinceReferenceDate
branch = member.branch
ports = member.ports
}
}

struct Request: Encodable {
var members: [Member]
var sections: [String]
var max_members: Int
var now: Double
}

struct Part: Decodable {
var kind: String
var count: Int
}

struct Line: Decodable {
var line: String
var parts: [Part]?
var count: Int?
var title: String?
var age_seconds: Double?
var just_now: Bool?
var listed: String?
var more: Int?
}

struct Block: Decodable {
var section: String
var tone: GroupHoverCardSummary.Tone
var lines: [Line]

func words(_ kind: GroupHoverCardSummary.Kind) -> GroupHoverCardSummary.Block? {
guard let section = GroupHoverCardOptions.Section(rawValue: section) else { return nil }
return GroupHoverCardSummary.Block(section: section, lines: lines.map { $0.words(kind) }, tone: tone)
}
}

struct Response: Decodable {
var blocks: [Block]
}

static func run(_ request: Request) -> Response? {
guard let data = try? JSONEncoder().encode(request) else { return nil }
var capacity = 4096
for _ in 0..<2 {
var output = [UInt8](repeating: 0, count: capacity); var outputLength = 0
let code: Int32 = data.withUnsafeBytes { requestBytes in "group_hover_summary".withCString { operation in output.withUnsafeMutableBytes { outputBytes in cmux_layout_reducer_json(requestBytes.bindMemory(to: UInt8.self).baseAddress, data.count, operation, outputBytes.bindMemory(to: UInt8.self).baseAddress, outputBytes.count, &outputLength) } } }
if code == CMUX_LAYOUT_REDUCER_ERR_BUFFER, outputLength > capacity {
capacity = outputLength
continue
}
guard code == CMUX_LAYOUT_REDUCER_OK, outputLength <= capacity else { return nil }
return try? JSONDecoder().decode(Response.self, from: Data(output.prefix(outputLength)))
}
return nil
}
}

nonisolated extension GroupHoverCardSummaryWire.Line {
/// The line in words.
func words(_ kind: GroupHoverCardSummary.Kind) -> String {
// "a, b, c, d +2 more": the rule's list, then how many it left out.
let list = (listed ?? "") + ((more ?? 0) > 0 ? " " + GroupHoverCardStrings.more(more ?? 0) : "")
switch line {
case "status":
let parts = (parts ?? []).map { part in
switch part.kind {
case "working": GroupHoverCardStrings.working(part.count)
case "waiting": GroupHoverCardStrings.waiting(part.count)
case "failed": GroupHoverCardStrings.failed(part.count)
default: GroupHoverCardStrings.unread(part.count)
}
}
return parts.isEmpty ? GroupHoverCardStrings.allIdle : parts.joined(separator: " \u{00B7} ")
case "count": return GroupHoverCardStrings.count(kind, count ?? 0)
case "member": return "\u{2022} " + (title ?? "")
case "more": return GroupHoverCardStrings.more(count ?? 0)
case "latest":
return GroupHoverCardStrings.latest(title ?? "", age: GroupHoverCardSummary.age(seconds: age_seconds ?? 0, justNow: just_now ?? true))
case "branches": return GroupHoverCardStrings.branches(list)
default: return GroupHoverCardStrings.ports(list)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import CCmuxMotionFFI
import Foundation
import CmuxNextDesignTokens

/// The C forms of `HoverCardMachine`'s inputs and outputs: each `HoverTargetID` as the 64-bit
/// handle cmux-motion's hover card machine compares, and the events and effects as the
/// `cmux_motion_ffi.h` structs. No rule lives here; the machine is Rust's.
nonisolated struct HoverCardHandles: Sendable {
private var handles: [HoverTargetID: UInt64] = [:]
private var ids: [UInt64: HoverTargetID] = [:]
private var nextHandle: UInt64 = 1

/// `HoverSuppression` in the rule's bit order (bit 0 drag, 1 scroll, 2 sidebarHide).
static let suppressionOrder: [HoverSuppression] = [.drag, .scroll, .sidebarHide]

/// `event` as the C event (kind numbers as in `cmux_motion_ffi.h`).
mutating func cEvent(_ event: HoverCardEvent) -> CmuxHoverCardEvent {
var raw = CmuxHoverCardEvent()
switch event {
case .hit(let target, let moved, let aiming, let bridging):
raw.kind = 0
raw.flag = target != nil
if let target { raw.target = cTarget(target) }
raw.moved = moved
raw.aiming = aiming
raw.bridging = bridging
case .pointerOnCard(let on):
raw.kind = 1
raw.flag = on
case .deadline(let token):
raw.kind = 2
raw.token = UInt64(truncatingIfNeeded: token)
case .dismiss:
raw.kind = 3
case .suppress(let reason):
raw.kind = 4
raw.suppression = Self.bit(reason)
case .unsuppress(let reason):
raw.kind = 5
raw.suppression = Self.bit(reason)
case .targetRemoved(let id):
raw.kind = 6
// An id with no handle is neither the last hit nor the active target: handle 0 matches none.
raw.target.id = handles[id] ?? 0
case .pin(let target):
raw.kind = 7
raw.target = cTarget(target)
case .owned(let shown):
raw.kind = 8
raw.flag = shown
}
return raw
}

/// The C effects as Swift effects, in order (kind 0 schedule, 1 cancelTimer, 2 show, 3 hide).
func effects(_ raw: some Sequence<CmuxHoverCardEffect>) -> [HoverCardEffect] {
raw.compactMap { (effect: CmuxHoverCardEffect) -> HoverCardEffect? in
switch effect.kind {
case 0: .schedule(token: Int(truncatingIfNeeded: effect.token), after: Duration(hoverNanoseconds: effect.after))
case 1: .cancelTimer
case 2: target(effect.target).map { .show($0, sliding: effect.sliding) }
case 3: .hide
default: nil
}
}
}

/// The target the C target names, nil for a handle no longer held.
func target(_ raw: CmuxHoverTarget) -> HoverTarget? {
guard let id = ids[raw.id] else { return nil }
let leave: HoverCardLeave = raw.leave == 1 ? .hides(reshow: Duration(hoverNanoseconds: raw.reshow)) : .lingers
return HoverTarget(id: id, window: Int(truncatingIfNeeded: raw.window), delay: Duration(hoverNanoseconds: raw.delay), leave: leave)
}

/// Keeps only the handles `state` names (its phase's target and the last hit): the rule
/// compares a new target only with those, so a dropped id gets a fresh handle when it returns.
mutating func prune(keeping state: CmuxHoverCardMachine) {
let phaseHolds = (1...4).contains(state.phase)
let keep = Set([phaseHolds ? state.target.id : nil, state.has_last_hit ? state.last_hit.id : nil].compactMap(\.self))
guard ids.count > keep.count else { return }
ids = ids.filter { keep.contains($0.key) }
handles = handles.filter { keep.contains($0.value) }
}

private static func bit(_ reason: HoverSuppression) -> UInt8 {
UInt8(suppressionOrder.firstIndex(of: reason) ?? 0)
}

private mutating func cTarget(_ target: HoverTarget) -> CmuxHoverTarget {
var raw = CmuxHoverTarget()
raw.id = handle(target.id)
raw.window = Int64(truncatingIfNeeded: target.window)
raw.delay = target.delay.hoverNanoseconds
if case .hides(let reshow) = target.leave {
raw.leave = 1
raw.reshow = reshow.hoverNanoseconds
}
return raw
}

private mutating func handle(_ id: HoverTargetID) -> UInt64 {
if let known = handles[id] { return known }
let fresh = nextHandle
nextHandle &+= 1
handles[id] = fresh
ids[fresh] = id
return fresh
}
}
Loading
Loading