feat(mac): allow Canvas placement and resizing

This commit is contained in:
Peter Steinberger
2025-12-12 20:28:19 +00:00
parent 356b6e0483
commit 296c0a6b70
6 changed files with 209 additions and 30 deletions

View File

@@ -1,4 +1,5 @@
import AppKit
import ClawdisIPC
import Foundation
@MainActor
@@ -16,11 +17,12 @@ final class CanvasManager {
return base.appendingPathComponent("Clawdis/canvas", isDirectory: true)
}()
func show(sessionKey: String, path: String? = nil) throws -> String {
func show(sessionKey: String, path: String? = nil, placement: CanvasPlacement? = nil) throws -> String {
let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
if let controller = self.panelController, self.panelSessionKey == session {
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
controller.applyPreferredPlacement(placement)
controller.goto(path: path ?? "/")
return controller.directoryPath
}
@@ -36,6 +38,7 @@ final class CanvasManager {
presentation: .panel(anchorProvider: anchorProvider))
self.panelController = controller
self.panelSessionKey = session
controller.applyPreferredPlacement(placement)
controller.showCanvas(path: path ?? "/")
return controller.directoryPath
}
@@ -50,8 +53,8 @@ final class CanvasManager {
self.panelController?.hideCanvas()
}
func goto(sessionKey: String, path: String) throws {
_ = try self.show(sessionKey: sessionKey, path: path)
func goto(sessionKey: String, path: String, placement: CanvasPlacement? = nil) throws {
_ = try self.show(sessionKey: sessionKey, path: path, placement: placement)
}
func eval(sessionKey: String, javaScript: String) async throws -> String {
@@ -74,4 +77,6 @@ final class CanvasManager {
let pt = NSEvent.mouseLocation
return NSRect(x: pt.x, y: pt.y, width: 1, height: 1)
}
// placement interpretation is handled by the window controller.
}

View File

@@ -1,4 +1,5 @@
import AppKit
import ClawdisIPC
import Foundation
import OSLog
import WebKit
@@ -10,6 +11,8 @@ private enum CanvasLayout {
static let panelSize = NSSize(width: 520, height: 680)
static let windowSize = NSSize(width: 1120, height: 840)
static let anchorPadding: CGFloat = 8
static let defaultPadding: CGFloat = 10
static let minPanelSize = NSSize(width: 360, height: 360)
}
final class CanvasPanel: NSPanel {
@@ -37,6 +40,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
private let watcher: CanvasFileWatcher
private let container: HoverChromeContainerView
let presentation: CanvasPresentation
private var preferredPlacement: CanvasPlacement?
var onVisibilityChanged: ((Bool) -> Void)?
@@ -86,6 +90,10 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
self.watcher.stop()
}
func applyPreferredPlacement(_ placement: CanvasPlacement?) {
self.preferredPlacement = placement
}
func showCanvas(path: String? = nil) {
if case .panel(let anchorProvider) = self.presentation {
self.presentAnchoredPanel(anchorProvider: anchorProvider)
@@ -203,7 +211,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
case .panel:
let panel = CanvasPanel(
contentRect: NSRect(origin: .zero, size: CanvasLayout.panelSize),
styleMask: [.borderless],
styleMask: [.borderless, .resizable],
backing: .buffered,
defer: false)
// Keep Canvas below the Voice Wake overlay panel.
@@ -218,6 +226,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
panel.contentView = contentView
panel.becomesKeyOnlyIfNeeded = true
panel.hidesOnDeactivate = false
panel.minSize = CanvasLayout.minPanelSize
return panel
}
}
@@ -233,24 +242,65 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
private func repositionPanel(using anchorProvider: () -> NSRect?) {
guard let panel = self.window else { return }
guard let anchor = anchorProvider() else { return }
var frame = panel.frame
let anchor = anchorProvider()
let screen = NSScreen.screens.first { screen in
screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY))
guard let anchor else { return false }
return screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY))
} ?? NSScreen.main
if let screen {
let minX = screen.frame.minX + CanvasLayout.anchorPadding
let maxX = screen.frame.maxX - frame.width - CanvasLayout.anchorPadding
frame.origin.x = min(max(round(anchor.midX - frame.width / 2), minX), maxX)
let desiredY = anchor.minY - frame.height - CanvasLayout.anchorPadding
frame.origin.y = max(desiredY, screen.frame.minY + CanvasLayout.anchorPadding)
} else {
frame.origin.x = round(anchor.midX - frame.width / 2)
frame.origin.y = anchor.minY - frame.height
if let placement = self.preferredPlacement,
let rect = self.frame(for: placement, panel: panel, screen: screen)
{
self.setPanelFrame(rect, on: screen)
return
}
panel.setFrame(frame, display: false)
if let restored = Self.loadRestoredFrame(sessionKey: self.sessionKey) {
self.setPanelFrame(restored, on: screen)
return
}
// Default: top-right corner of the visible frame.
let visible = (screen?.visibleFrame ?? NSScreen.main?.visibleFrame) ?? panel.frame
let w = max(CanvasLayout.minPanelSize.width, panel.frame.width)
let h = max(CanvasLayout.minPanelSize.height, panel.frame.height)
let x = visible.maxX - w - CanvasLayout.defaultPadding
let y = visible.maxY - h - CanvasLayout.defaultPadding
self.setPanelFrame(NSRect(x: x, y: y, width: w, height: h), on: screen)
}
private func frame(for placement: CanvasPlacement, panel: NSWindow, screen: NSScreen?) -> NSRect? {
let visible = (screen?.visibleFrame ?? NSScreen.main?.visibleFrame) ?? panel.frame
let cur = panel.frame
let width = placement.width.map { max(CanvasLayout.minPanelSize.width, CGFloat($0)) } ?? cur.size.width
let height = placement.height.map { max(CanvasLayout.minPanelSize.height, CGFloat($0)) } ?? cur.size.height
let size = NSSize(width: width, height: height)
let origin: NSPoint = {
// If any origin component is provided, apply it and keep the other coordinate stable.
if placement.x != nil || placement.y != nil {
return NSPoint(x: placement.x ?? cur.origin.x, y: placement.y ?? cur.origin.y)
}
// Default: top-right.
return NSPoint(
x: visible.maxX - size.width - CanvasLayout.defaultPadding,
y: visible.maxY - size.height - CanvasLayout.defaultPadding)
}()
return NSRect(origin: origin, size: size)
}
private func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) {
guard let panel = self.window else { return }
let s = screen ?? panel.screen ?? NSScreen.main
let constrained: NSRect
if let s {
constrained = panel.constrainFrameRect(frame, to: s)
} else {
constrained = frame
}
panel.setFrame(constrained, display: false)
}
// MARK: - WKNavigationDelegate
@@ -279,6 +329,19 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
self.onVisibilityChanged?(false)
}
func windowDidMove(_: Notification) {
self.persistFrameIfPanel()
}
func windowDidEndLiveResize(_: Notification) {
self.persistFrameIfPanel()
}
private func persistFrameIfPanel() {
guard case .panel = self.presentation, let window else { return }
Self.storeRestoredFrame(window.frame, sessionKey: self.sessionKey)
}
// MARK: - Helpers
private static func sanitizeSessionKey(_ key: String) -> String {
@@ -288,6 +351,23 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
let scalars = trimmed.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
return String(scalars)
}
private static func storedFrameDefaultsKey(sessionKey: String) -> String {
"clawdis.canvas.frame.\(sanitizeSessionKey(sessionKey))"
}
private static func loadRestoredFrame(sessionKey: String) -> NSRect? {
let key = storedFrameDefaultsKey(sessionKey: sessionKey)
guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil }
let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3])
if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil }
return rect
}
private static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) {
let key = storedFrameDefaultsKey(sessionKey: sessionKey)
UserDefaults.standard.set([Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)], forKey: key)
}
}
// MARK: - Hover chrome container
@@ -354,10 +434,43 @@ private final class CanvasDragHandleView: NSView {
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
}
private final class CanvasResizeHandleView: NSView {
private var startPoint: NSPoint = .zero
private var startFrame: NSRect = .zero
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
override func mouseDown(with event: NSEvent) {
guard let window else { return }
_ = window.makeFirstResponder(self)
self.startPoint = NSEvent.mouseLocation
self.startFrame = window.frame
super.mouseDown(with: event)
}
override func mouseDragged(with _: NSEvent) {
guard let window else { return }
let current = NSEvent.mouseLocation
let dx = current.x - self.startPoint.x
let dy = current.y - self.startPoint.y
var frame = self.startFrame
frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx)
frame.origin.y += dy
frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy)
if let screen = window.screen {
frame = window.constrainFrameRect(frame, to: screen)
}
window.setFrame(frame, display: true)
}
}
private final class CanvasChromeOverlayView: NSView {
var onClose: (() -> Void)?
private let dragHandle = CanvasDragHandleView(frame: .zero)
private let resizeHandle = CanvasResizeHandleView(frame: .zero)
private let closeButton: NSButton = {
let img = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Close")
?? NSImage(size: NSSize(width: 18, height: 18))
@@ -385,6 +498,11 @@ private final class CanvasChromeOverlayView: NSView {
self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor
self.addSubview(self.dragHandle)
self.resizeHandle.translatesAutoresizingMaskIntoConstraints = false
self.resizeHandle.wantsLayer = true
self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor
self.addSubview(self.resizeHandle)
self.closeButton.translatesAutoresizingMaskIntoConstraints = false
self.closeButton.target = self
self.closeButton.action = #selector(self.handleClose)
@@ -400,6 +518,11 @@ private final class CanvasChromeOverlayView: NSView {
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
self.closeButton.widthAnchor.constraint(equalToConstant: 18),
self.closeButton.heightAnchor.constraint(equalToConstant: 18),
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
self.resizeHandle.widthAnchor.constraint(equalToConstant: 18),
self.resizeHandle.heightAnchor.constraint(equalToConstant: 18),
])
}
@@ -412,6 +535,7 @@ private final class CanvasChromeOverlayView: NSView {
if self.closeButton.frame.contains(point) { return self.closeButton }
if self.dragHandle.frame.contains(point) { return self.dragHandle }
if self.resizeHandle.frame.contains(point) { return self.resizeHandle }
return nil
}

View File

@@ -85,12 +85,12 @@ enum ControlRequestHandler {
? Response(ok: true, message: rpcResult.text ?? "sent")
: Response(ok: false, message: rpcResult.error ?? "failed to send")
case let .canvasShow(session, path):
case let .canvasShow(session, path, placement):
guard canvasEnabled else {
return Response(ok: false, message: "Canvas disabled by user")
}
do {
let dir = try await MainActor.run { try CanvasManager.shared.show(sessionKey: session, path: path) }
let dir = try await MainActor.run { try CanvasManager.shared.show(sessionKey: session, path: path, placement: placement) }
return Response(ok: true, message: dir)
} catch {
return Response(ok: false, message: error.localizedDescription)
@@ -100,12 +100,12 @@ enum ControlRequestHandler {
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
return Response(ok: true)
case let .canvasGoto(session, path):
case let .canvasGoto(session, path, placement):
guard canvasEnabled else {
return Response(ok: false, message: "Canvas disabled by user")
}
do {
try await MainActor.run { try CanvasManager.shared.goto(sessionKey: session, path: path) }
try await MainActor.run { try CanvasManager.shared.goto(sessionKey: session, path: path, placement: placement) }
return Response(ok: true)
} catch {
return Response(ok: false, message: error.localizedDescription)

View File

@@ -171,15 +171,26 @@ struct ClawdisCLI {
case "show":
var session = "main"
var path: String?
var x: Double?
var y: Double?
var width: Double?
var height: Double?
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--session": session = args.popFirst() ?? session
case "--path": path = args.popFirst()
case "--x": x = args.popFirst().flatMap(Double.init)
case "--y": y = args.popFirst().flatMap(Double.init)
case "--width": width = args.popFirst().flatMap(Double.init)
case "--height": height = args.popFirst().flatMap(Double.init)
default: break
}
}
return .canvasShow(session: session, path: path)
let placement = (x != nil || y != nil || width != nil || height != nil)
? CanvasPlacement(x: x, y: y, width: width, height: height)
: nil
return .canvasShow(session: session, path: path, placement: placement)
case "hide":
var session = "main"
@@ -195,16 +206,27 @@ struct ClawdisCLI {
case "goto":
var session = "main"
var path: String?
var x: Double?
var y: Double?
var width: Double?
var height: Double?
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--session": session = args.popFirst() ?? session
case "--path": path = args.popFirst()
case "--x": x = args.popFirst().flatMap(Double.init)
case "--y": y = args.popFirst().flatMap(Double.init)
case "--width": width = args.popFirst().flatMap(Double.init)
case "--height": height = args.popFirst().flatMap(Double.init)
default: break
}
}
guard let path else { throw CLIError.help }
return .canvasGoto(session: session, path: path)
let placement = (x != nil || y != nil || width != nil || height != nil)
? CanvasPlacement(x: x, y: y, width: width, height: height)
: nil
return .canvasGoto(session: session, path: path, placement: placement)
case "eval":
var session = "main"
@@ -260,8 +282,10 @@ struct ClawdisCLI {
clawdis-mac agent --message <text> [--thinking <low|default|high>]
[--session <key>] [--deliver] [--to <E.164>]
clawdis-mac canvas show [--session <key>] [--path </...>]
[--x <screenX> --y <screenY>] [--width <w> --height <h>]
clawdis-mac canvas hide [--session <key>]
clawdis-mac canvas goto --path </...> [--session <key>]
[--x <screenX> --y <screenY>] [--width <w> --height <h>]
clawdis-mac canvas eval --js <code> [--session <key>]
clawdis-mac canvas snapshot [--out <path>] [--session <key>]
clawdis-mac --help

View File

@@ -31,6 +31,24 @@ public enum NotificationDelivery: String, Codable, Sendable {
case auto
}
// MARK: - Canvas geometry
/// Optional placement hints for the Canvas panel.
/// Values are in screen coordinates (same as `NSWindow` frame).
public struct CanvasPlacement: Codable, Sendable {
public var x: Double?
public var y: Double?
public var width: Double?
public var height: Double?
public init(x: Double? = nil, y: Double? = nil, width: Double? = nil, height: Double? = nil) {
self.x = x
self.y = y
self.width = width
self.height = height
}
}
public enum Request: Sendable {
case notify(
title: String,
@@ -49,9 +67,9 @@ public enum Request: Sendable {
case status
case agent(message: String, thinking: String?, session: String?, deliver: Bool, to: String?)
case rpcStatus
case canvasShow(session: String, path: String?)
case canvasShow(session: String, path: String?, placement: CanvasPlacement?)
case canvasHide(session: String)
case canvasGoto(session: String, path: String)
case canvasGoto(session: String, path: String, placement: CanvasPlacement?)
case canvasEval(session: String, javaScript: String)
case canvasSnapshot(session: String, outPath: String?)
}
@@ -85,6 +103,7 @@ extension Request: Codable {
case path
case javaScript
case outPath
case placement
}
private enum Kind: String, Codable {
@@ -146,19 +165,21 @@ extension Request: Codable {
case .rpcStatus:
try container.encode(Kind.rpcStatus, forKey: .type)
case let .canvasShow(session, path):
case let .canvasShow(session, path, placement):
try container.encode(Kind.canvasShow, forKey: .type)
try container.encode(session, forKey: .session)
try container.encodeIfPresent(path, forKey: .path)
try container.encodeIfPresent(placement, forKey: .placement)
case let .canvasHide(session):
try container.encode(Kind.canvasHide, forKey: .type)
try container.encode(session, forKey: .session)
case let .canvasGoto(session, path):
case let .canvasGoto(session, path, placement):
try container.encode(Kind.canvasGoto, forKey: .type)
try container.encode(session, forKey: .session)
try container.encode(path, forKey: .path)
try container.encodeIfPresent(placement, forKey: .placement)
case let .canvasEval(session, javaScript):
try container.encode(Kind.canvasEval, forKey: .type)
@@ -220,7 +241,8 @@ extension Request: Codable {
case .canvasShow:
let session = try container.decode(String.self, forKey: .session)
let path = try container.decodeIfPresent(String.self, forKey: .path)
self = .canvasShow(session: session, path: path)
let placement = try container.decodeIfPresent(CanvasPlacement.self, forKey: .placement)
self = .canvasShow(session: session, path: path, placement: placement)
case .canvasHide:
let session = try container.decode(String.self, forKey: .session)
@@ -229,7 +251,8 @@ extension Request: Codable {
case .canvasGoto:
let session = try container.decode(String.self, forKey: .session)
let path = try container.decode(String.self, forKey: .path)
self = .canvasGoto(session: session, path: path)
let placement = try container.decodeIfPresent(CanvasPlacement.self, forKey: .placement)
self = .canvasGoto(session: session, path: path, placement: placement)
case .canvasEval:
let session = try container.decode(String.self, forKey: .session)