Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -1,6 +1,7 @@
#if DEBUG || CMUX_BENCH_HOOKS
import AppKit
import CmuxNextBridge
import CmuxNextLayout
import CmuxNextSettings

/// `debug.mouse` points named by what is under them, so a tour can drag a sidebar row onto the shown workspace on any
Expand All @@ -18,9 +19,25 @@ extension DebugMouse {
return InputJournal.topLeftPoint(center, in: window, relativeTo: window)
}

/// Where a `drag` ends: `to_x`,`to_y`, or 10% in from the shown workspace's `to_edge`; else where it started.
/// The center of a pane divider of the shown workspace (top-left window-local points): `first` (the topmost, then
/// leftmost) or a divider's mouse-area id or its prefix (`split:`, `column:`, `row:`), so a tour can hover or drag a
/// divider on any runner's window size.
static func dividerPoint(_ name: String, controller: WindowController, window: NSWindow) -> NSPoint? {
guard let root = controller.content?.layoutView else { return nil }
let areas = root.dividerMouseAreas.sorted { ($0.rect.minY, $0.rect.minX) < ($1.rect.minY, $1.rect.minX) }
guard let area = name == "first" ? areas.first : areas.first(where: { $0.id == name }) ?? areas.first(where: { $0.id.hasPrefix(name) })
else { return nil }
let center = root.convert(NSPoint(x: area.rect.midX, y: area.rect.midY), to: nil)
return InputJournal.topLeftPoint(center, in: window, relativeTo: window)
}

/// Where a `drag` ends: `to_x`,`to_y`, `by_x`,`by_y` from where it starts, or 10% in from the shown workspace's
/// `to_edge`; else where it started.
static func dragTarget(_ params: [String: JSONValue], from point: NSPoint, controller: WindowController, window: NSWindow) -> NSPoint {
if let x = params["to_x"]?.doubleValue, let y = params["to_y"]?.doubleValue { return NSPoint(x: x, y: y) }
if params["by_x"] != nil || params["by_y"] != nil {
return NSPoint(x: point.x + (params["by_x"]?.doubleValue ?? 0), y: point.y + (params["by_y"]?.doubleValue ?? 0))
}
guard let edge = params["to_edge"]?.stringValue, let view = controller.content?.contentView else { return point }
let r = view.convert(view.bounds, to: nil) // window coordinates, y up
let (dx, dy) = (r.width * 0.1, r.height * 0.1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import CmuxNextSettings
///
/// Params: `window` (id; default the first window); a point as `x`,`y`
/// (window-local points from the top-left, as the journal records them) or
/// `pane` (the center of that pane's content) or `row` (a sidebar workspace row, `DebugMouse+Rows`); `action`: `click` (default),
/// `pane` (the center of that pane's content) or `row` (a sidebar workspace row, `DebugMouse+Rows`) or `divider` (a pane
/// divider's center, `DebugMouse+Rows`); `action`: `click` (default),
/// `double_click`, `down`, `up`, `drag` (to `to_x`,`to_y`
/// or `to_edge` of the shown workspace, in `steps`), `move` (pointer motion with no button, for hover: tab and workspace
/// hover cards), `hover` (tracking-area owners get entered, moved and
Expand Down Expand Up @@ -144,6 +145,7 @@ enum DebugMouse {
private static func point(_ params: [String: JSONValue], controller: WindowController, window: NSWindow) -> NSPoint? {
if let x = params["x"]?.doubleValue, let y = params["y"]?.doubleValue { return NSPoint(x: x, y: y) }
if let row = params["row"]?.stringValue { return rowPoint(row, controller: controller, window: window) }
if let divider = params["divider"]?.stringValue { return dividerPoint(divider, controller: controller, window: window) }
guard let key = params["pane"]?.stringValue, let pane = controller.content?.paneController(key: key) else { return nil }
let view: NSView = pane.view.content ?? pane.view
let center = view.convert(NSPoint(x: view.bounds.midX, y: view.bounds.midY), to: nil)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@ final class RightSidebarResizeHandle: NSView {
private var hover: PointerHover?
private var startX: CGFloat = 0
private let line = CALayer()
/// The accent band on the edge while hovered or dragged (`DividerGlowBand`).
let glow = DividerGlowBand()

override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
line.opacity = 0
layer?.addSublayer(line)
layer?.addSublayer(glow.layer)
setAccessibilityElement(true)
setAccessibilityRole(.splitter)
setAccessibilityLabel(RightSidebarStrings.resize)
Expand All @@ -55,6 +58,9 @@ final class RightSidebarResizeHandle: NSView {
let x = edge == .minX ? 0 : bounds.width - width
line.frame = CGRect(x: x, y: 0, width: width, height: bounds.height)
CATransaction.commit()
// The band's core sits just inside the edge, so the strip's own bounds hold it.
let core = DividerGlowBand.coreThickness
glow.place(along: CGRect(x: edge == .minX ? 0 : bounds.width - core, y: 0, width: core, height: bounds.height), vertical: true)
}

override func viewDidMoveToWindow() {
Expand All @@ -73,12 +79,20 @@ final class RightSidebarResizeHandle: NSView {
hover?.refresh()
}

/// Removed mid-drag: the release never arrives here, so the drag (and its band) ends now.
override func viewWillMove(toSuperview newSuperview: NSView?) {
super.viewWillMove(toSuperview: newSuperview)
if newSuperview == nil { isDragging = false }
}

private func applyLineTheme() {
performWithTheme { line.backgroundColor = Palette.separator.cgColor }
glow.applyTheme(self)
needsLayout = true
}

private func updateLine() {
glow.set(isDragging ? .dragging : isHovered ? .hovered : .rest)
let target: Float = isHovered || isDragging ? 1 : 0
guard line.opacity != target else { return }
Motion.sidebar.set(line, "opacity", to: target, fade: .hover)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,21 @@ import CmuxNextDesign
/// side items of the DevTools menu.
final class CEFDevToolsDivider: NSView {
weak var devTools: CEFDevToolsController?
private var dragging = false
private var dragging = false { didSet { updateGlow() } }
private var hovered = false { didSet { updateGlow() } }
/// The accent band over the line while hovered or dragged (`DividerGlowBand`); it stays
/// inside this view, which the page and DevTools windows leave uncovered.
let glow = DividerGlowBand()
private var trackingArea: NSTrackingArea?

override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
layer?.addSublayer(glow.layer)
}

@available(*, unavailable)
required init?(coder: NSCoder) { nil }

nonisolated override var isFlipped: Bool { false }
override var mouseDownCanMoveWindow: Bool { false }
Expand All @@ -24,6 +38,55 @@ final class CEFDevToolsDivider: NSView {
line.fill()
}

override func layout() {
super.layout()
let side = devTools?.layout.dock.isSide ?? true
let t = CEFDevToolsLayout.lineThickness
let line = side
? NSRect(x: bounds.midX - t / 2, y: 0, width: t, height: bounds.height)
: NSRect(x: 0, y: bounds.midY - t / 2, width: bounds.width, height: t)
glow.place(along: line, vertical: side)
}

override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
needsLayout = true
}

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
glow.applyTheme(self)
}

override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
glow.applyTheme(self)
}

override func updateTrackingAreas() {
super.updateTrackingAreas()
if let trackingArea { removeTrackingArea(trackingArea) }
let area = NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], owner: self)
addTrackingArea(area)
trackingArea = area
}

override func mouseEntered(with event: NSEvent) { hovered = true }
override func mouseExited(with event: NSEvent) { hovered = false }

/// Removed mid-hover or mid-drag: no exit or release arrives, so the band goes out with it.
override func viewWillMove(toSuperview newSuperview: NSView?) {
super.viewWillMove(toSuperview: newSuperview)
if newSuperview == nil {
dragging = false
hovered = false
}
}

private func updateGlow() {
glow.set(dragging ? .dragging : hovered ? .hovered : .rest)
}

override func resetCursorRects() {
addCursorRect(bounds, cursor: devTools?.layout.dock.isSide == true ? .resizeLeftRight : .resizeUpDown)
}
Expand Down
115 changes: 115 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DividerGlowBand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
public import AppKit
public import QuartzCore

/// The lit band a resize divider shows while it is hovered or dragged: a thin core in the theme's
/// accent with a soft halo around it, centered on the divider's line. Nothing shows at rest, so
/// a divider keeps its resting color and thickness; the band only adds light over it.
///
/// One instance per divider. It is a pair of plain layers (no timers, no display link): a state
/// change fades the band's opacity once through `Motion.feedback`, which ends on its own and
/// is instant under Reduce Motion, calm mode or animation speed off. Going out is always instant: a
/// pane divider's Chromium hole closes with the state change, so a fading halo there would be cut
/// off mid-fade (fading out is optional; opening never waits). There is no pulse or loop.
@MainActor
public final class DividerGlowBand {
/// How lit the band is.
public enum State: Equatable, Sendable {
/// Nothing drawn.
case rest
/// The pointer is over the divider: a dimmer band.
case hovered
/// A drag runs: the full band.
case dragging
}

/// The core's thickness across the divider.
public static let coreThickness: CGFloat = 2
/// The halo's blur radius around the core.
public static let haloRadius: CGFloat = 3
/// How far the band reaches from the line's center, on each side: a Chromium hole that keeps
/// the band visible over a page must be this wide each way.
public static var reach: CGFloat { coreThickness / 2 + haloRadius }

/// Opacity of the whole band at each state.
public static func opacity(_ state: State) -> Float {
switch state {
case .rest: 0
case .hovered: 0.55
case .dragging: 1
}
}

/// The band's container: add it to the divider's layer, above its line.
public let layer = CALayer()
private let core = CALayer()
public private(set) var state: State = .rest
private var vertical = true

public init() {
layer.opacity = 0
layer.masksToBounds = false
layer.addSublayer(core)
core.cornerRadius = Self.coreThickness / 2
core.shadowOffset = .zero
core.shadowRadius = Self.haloRadius
core.shadowOpacity = 0.9
layer.actions = ["position": NSNull(), "bounds": NSNull(), "frame": NSNull()]
core.actions = ["position": NSNull(), "bounds": NSNull(), "frame": NSNull(), "shadowPath": NSNull(),
"backgroundColor": NSNull(), "shadowColor": NSNull()]
}

/// Places the band along `line` (the divider's drawn line, in the host layer's coordinates),
/// centered across it. `vertical` is true for a line that runs top to bottom.
public func place(along line: CGRect, vertical: Bool) {
CATransaction.begin()
CATransaction.setDisableActions(true)
self.vertical = vertical
let t = Self.coreThickness
let frame = vertical
? CGRect(x: line.midX - t / 2, y: line.minY, width: t, height: line.height)
: CGRect(x: line.minX, y: line.midY - t / 2, width: line.width, height: t)
layer.frame = frame
core.frame = layer.bounds
core.shadowPath = CGPath(roundedRect: core.bounds, cornerWidth: t / 2, cornerHeight: t / 2, transform: nil)
CATransaction.commit()
}

/// Whether the band is lit (hovered or dragging), from the model state: a Chromium hole that
/// shows it opens on the change, not after the fade's first frame.
public var isLit: Bool { state != .rest }

/// The band's extent across the divider (core plus halo) in the host layer's coordinates, as
/// long as the line; `.null` at rest.
public var extent: CGRect {
guard isLit else { return .null }
let r = Self.haloRadius
return vertical ? layer.frame.insetBy(dx: -r, dy: 0) : layer.frame.insetBy(dx: 0, dy: -r)
}

/// Repaints in the current theme's accent (`Palette.accent`). Call on appearance changes.
public func applyTheme(_ view: NSView) {
CATransaction.begin()
CATransaction.setDisableActions(true)
view.performWithTheme {
core.backgroundColor = Palette.accent.cgColor
core.shadowColor = Palette.accent.cgColor
}
CATransaction.commit()
}

/// Lights or dims the band: one fade on `Motion.feedback` (instant when motion is off); back to
/// rest at once.
public func set(_ state: State) {
guard state != self.state else { return }
self.state = state
if state == .rest {
layer.removeAnimation(forKey: "opacity")
CATransaction.begin()
CATransaction.setDisableActions(true)
layer.opacity = 0
CATransaction.commit()
return
}
Motion.feedback.set(layer, "opacity", to: Self.opacity(state), fade: .hover)
}
}
Loading
Loading