From 296c0a6b706ad8c6a7df0021c66b4260e0363be0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 12 Dec 2025 20:28:19 +0000 Subject: [PATCH] feat(mac): allow Canvas placement and resizing --- .../macos/Sources/Clawdis/CanvasManager.swift | 11 +- apps/macos/Sources/Clawdis/CanvasWindow.swift | 154 ++++++++++++++++-- .../Clawdis/ControlRequestHandler.swift | 8 +- apps/macos/Sources/ClawdisCLI/main.swift | 28 +++- apps/macos/Sources/ClawdisIPC/IPC.swift | 35 +++- docs/mac/canvas.md | 3 + 6 files changed, 209 insertions(+), 30 deletions(-) diff --git a/apps/macos/Sources/Clawdis/CanvasManager.swift b/apps/macos/Sources/Clawdis/CanvasManager.swift index 51d452486..b43f5e65b 100644 --- a/apps/macos/Sources/Clawdis/CanvasManager.swift +++ b/apps/macos/Sources/Clawdis/CanvasManager.swift @@ -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. } diff --git a/apps/macos/Sources/Clawdis/CanvasWindow.swift b/apps/macos/Sources/Clawdis/CanvasWindow.swift index 8e8d37ece..7d0cb2b5e 100644 --- a/apps/macos/Sources/Clawdis/CanvasWindow.swift +++ b/apps/macos/Sources/Clawdis/CanvasWindow.swift @@ -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 } diff --git a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift index 275198092..58598ad99 100644 --- a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift +++ b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift @@ -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) diff --git a/apps/macos/Sources/ClawdisCLI/main.swift b/apps/macos/Sources/ClawdisCLI/main.swift index b013e4713..d89daf2be 100644 --- a/apps/macos/Sources/ClawdisCLI/main.swift +++ b/apps/macos/Sources/ClawdisCLI/main.swift @@ -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 [--thinking ] [--session ] [--deliver] [--to ] clawdis-mac canvas show [--session ] [--path ] + [--x --y ] [--width --height ] clawdis-mac canvas hide [--session ] clawdis-mac canvas goto --path [--session ] + [--x --y ] [--width --height ] clawdis-mac canvas eval --js [--session ] clawdis-mac canvas snapshot [--out ] [--session ] clawdis-mac --help diff --git a/apps/macos/Sources/ClawdisIPC/IPC.swift b/apps/macos/Sources/ClawdisIPC/IPC.swift index 5b831a0e5..db9134b0b 100644 --- a/apps/macos/Sources/ClawdisIPC/IPC.swift +++ b/apps/macos/Sources/ClawdisIPC/IPC.swift @@ -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) diff --git a/docs/mac/canvas.md b/docs/mac/canvas.md index 75158a974..aa3b87e97 100644 --- a/docs/mac/canvas.md +++ b/docs/mac/canvas.md @@ -56,6 +56,8 @@ Canvas is presented as a borderless `NSPanel` (similar to the existing WebChat p - Can be shown/hidden at any time by the agent. - Supports an “anchored” presentation (near the menu bar icon or another anchor rect). - Uses a rounded container; shadow stays on, but **chrome/bezel only appears on hover**. +- Default position is the **top-right corner** of the current screen’s visible frame (unless the user moved/resized it previously). +- The panel is **user-resizable** (edge resize + hover resize handle) and the last frame is persisted per session. ### Hover-only chrome @@ -73,6 +75,7 @@ Expose Canvas via the existing `clawdis-mac` → XPC → app routing so the agen - Evaluate JavaScript and optionally return results. - Query/modify DOM (helpers mirroring “dom query/all/attr/click/type/wait” patterns). - Capture a snapshot image of the current canvas view. +- Optionally set panel placement (screen `x/y` + `width/height`) when showing/navigating. This should be modeled after `WebChatManager`/`WebChatWindowController` but targeting `clawdis-canvas://…` URLs.