feat(mac): allow Canvas placement and resizing
parent
356b6e0483
commit
296c0a6b70
|
|
@ -1,4 +1,5 @@
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import ClawdisIPC
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|
@ -16,11 +17,12 @@ final class CanvasManager {
|
||||||
return base.appendingPathComponent("Clawdis/canvas", isDirectory: true)
|
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 anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider
|
||||||
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if let controller = self.panelController, self.panelSessionKey == session {
|
if let controller = self.panelController, self.panelSessionKey == session {
|
||||||
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
|
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||||
|
controller.applyPreferredPlacement(placement)
|
||||||
controller.goto(path: path ?? "/")
|
controller.goto(path: path ?? "/")
|
||||||
return controller.directoryPath
|
return controller.directoryPath
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +38,7 @@ final class CanvasManager {
|
||||||
presentation: .panel(anchorProvider: anchorProvider))
|
presentation: .panel(anchorProvider: anchorProvider))
|
||||||
self.panelController = controller
|
self.panelController = controller
|
||||||
self.panelSessionKey = session
|
self.panelSessionKey = session
|
||||||
|
controller.applyPreferredPlacement(placement)
|
||||||
controller.showCanvas(path: path ?? "/")
|
controller.showCanvas(path: path ?? "/")
|
||||||
return controller.directoryPath
|
return controller.directoryPath
|
||||||
}
|
}
|
||||||
|
|
@ -50,8 +53,8 @@ final class CanvasManager {
|
||||||
self.panelController?.hideCanvas()
|
self.panelController?.hideCanvas()
|
||||||
}
|
}
|
||||||
|
|
||||||
func goto(sessionKey: String, path: String) throws {
|
func goto(sessionKey: String, path: String, placement: CanvasPlacement? = nil) throws {
|
||||||
_ = try self.show(sessionKey: sessionKey, path: path)
|
_ = try self.show(sessionKey: sessionKey, path: path, placement: placement)
|
||||||
}
|
}
|
||||||
|
|
||||||
func eval(sessionKey: String, javaScript: String) async throws -> String {
|
func eval(sessionKey: String, javaScript: String) async throws -> String {
|
||||||
|
|
@ -74,4 +77,6 @@ final class CanvasManager {
|
||||||
let pt = NSEvent.mouseLocation
|
let pt = NSEvent.mouseLocation
|
||||||
return NSRect(x: pt.x, y: pt.y, width: 1, height: 1)
|
return NSRect(x: pt.x, y: pt.y, width: 1, height: 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// placement interpretation is handled by the window controller.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import ClawdisIPC
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
@ -10,6 +11,8 @@ private enum CanvasLayout {
|
||||||
static let panelSize = NSSize(width: 520, height: 680)
|
static let panelSize = NSSize(width: 520, height: 680)
|
||||||
static let windowSize = NSSize(width: 1120, height: 840)
|
static let windowSize = NSSize(width: 1120, height: 840)
|
||||||
static let anchorPadding: CGFloat = 8
|
static let anchorPadding: CGFloat = 8
|
||||||
|
static let defaultPadding: CGFloat = 10
|
||||||
|
static let minPanelSize = NSSize(width: 360, height: 360)
|
||||||
}
|
}
|
||||||
|
|
||||||
final class CanvasPanel: NSPanel {
|
final class CanvasPanel: NSPanel {
|
||||||
|
|
@ -37,6 +40,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||||
private let watcher: CanvasFileWatcher
|
private let watcher: CanvasFileWatcher
|
||||||
private let container: HoverChromeContainerView
|
private let container: HoverChromeContainerView
|
||||||
let presentation: CanvasPresentation
|
let presentation: CanvasPresentation
|
||||||
|
private var preferredPlacement: CanvasPlacement?
|
||||||
|
|
||||||
var onVisibilityChanged: ((Bool) -> Void)?
|
var onVisibilityChanged: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
|
@ -86,6 +90,10 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||||
self.watcher.stop()
|
self.watcher.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyPreferredPlacement(_ placement: CanvasPlacement?) {
|
||||||
|
self.preferredPlacement = placement
|
||||||
|
}
|
||||||
|
|
||||||
func showCanvas(path: String? = nil) {
|
func showCanvas(path: String? = nil) {
|
||||||
if case .panel(let anchorProvider) = self.presentation {
|
if case .panel(let anchorProvider) = self.presentation {
|
||||||
self.presentAnchoredPanel(anchorProvider: anchorProvider)
|
self.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||||
|
|
@ -203,7 +211,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||||
case .panel:
|
case .panel:
|
||||||
let panel = CanvasPanel(
|
let panel = CanvasPanel(
|
||||||
contentRect: NSRect(origin: .zero, size: CanvasLayout.panelSize),
|
contentRect: NSRect(origin: .zero, size: CanvasLayout.panelSize),
|
||||||
styleMask: [.borderless],
|
styleMask: [.borderless, .resizable],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false)
|
defer: false)
|
||||||
// Keep Canvas below the Voice Wake overlay panel.
|
// Keep Canvas below the Voice Wake overlay panel.
|
||||||
|
|
@ -218,6 +226,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||||
panel.contentView = contentView
|
panel.contentView = contentView
|
||||||
panel.becomesKeyOnlyIfNeeded = true
|
panel.becomesKeyOnlyIfNeeded = true
|
||||||
panel.hidesOnDeactivate = false
|
panel.hidesOnDeactivate = false
|
||||||
|
panel.minSize = CanvasLayout.minPanelSize
|
||||||
return panel
|
return panel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -233,24 +242,65 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||||
|
|
||||||
private func repositionPanel(using anchorProvider: () -> NSRect?) {
|
private func repositionPanel(using anchorProvider: () -> NSRect?) {
|
||||||
guard let panel = self.window else { return }
|
guard let panel = self.window else { return }
|
||||||
guard let anchor = anchorProvider() else { return }
|
let anchor = anchorProvider()
|
||||||
|
|
||||||
var frame = panel.frame
|
|
||||||
let screen = NSScreen.screens.first { screen in
|
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
|
} ?? NSScreen.main
|
||||||
|
|
||||||
if let screen {
|
if let placement = self.preferredPlacement,
|
||||||
let minX = screen.frame.minX + CanvasLayout.anchorPadding
|
let rect = self.frame(for: placement, panel: panel, screen: screen)
|
||||||
let maxX = screen.frame.maxX - frame.width - CanvasLayout.anchorPadding
|
{
|
||||||
frame.origin.x = min(max(round(anchor.midX - frame.width / 2), minX), maxX)
|
self.setPanelFrame(rect, on: screen)
|
||||||
let desiredY = anchor.minY - frame.height - CanvasLayout.anchorPadding
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
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
|
// MARK: - WKNavigationDelegate
|
||||||
|
|
@ -279,6 +329,19 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||||
self.onVisibilityChanged?(false)
|
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
|
// MARK: - Helpers
|
||||||
|
|
||||||
private static func sanitizeSessionKey(_ key: String) -> String {
|
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) : "_" }
|
let scalars = trimmed.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
|
||||||
return String(scalars)
|
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
|
// MARK: - Hover chrome container
|
||||||
|
|
@ -354,10 +434,43 @@ private final class CanvasDragHandleView: NSView {
|
||||||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
|
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 {
|
private final class CanvasChromeOverlayView: NSView {
|
||||||
var onClose: (() -> Void)?
|
var onClose: (() -> Void)?
|
||||||
|
|
||||||
private let dragHandle = CanvasDragHandleView(frame: .zero)
|
private let dragHandle = CanvasDragHandleView(frame: .zero)
|
||||||
|
private let resizeHandle = CanvasResizeHandleView(frame: .zero)
|
||||||
private let closeButton: NSButton = {
|
private let closeButton: NSButton = {
|
||||||
let img = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Close")
|
let img = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Close")
|
||||||
?? NSImage(size: NSSize(width: 18, height: 18))
|
?? NSImage(size: NSSize(width: 18, height: 18))
|
||||||
|
|
@ -385,6 +498,11 @@ private final class CanvasChromeOverlayView: NSView {
|
||||||
self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor
|
self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor
|
||||||
self.addSubview(self.dragHandle)
|
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.translatesAutoresizingMaskIntoConstraints = false
|
||||||
self.closeButton.target = self
|
self.closeButton.target = self
|
||||||
self.closeButton.action = #selector(self.handleClose)
|
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.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
|
||||||
self.closeButton.widthAnchor.constraint(equalToConstant: 18),
|
self.closeButton.widthAnchor.constraint(equalToConstant: 18),
|
||||||
self.closeButton.heightAnchor.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.closeButton.frame.contains(point) { return self.closeButton }
|
||||||
if self.dragHandle.frame.contains(point) { return self.dragHandle }
|
if self.dragHandle.frame.contains(point) { return self.dragHandle }
|
||||||
|
if self.resizeHandle.frame.contains(point) { return self.resizeHandle }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,12 +85,12 @@ enum ControlRequestHandler {
|
||||||
? Response(ok: true, message: rpcResult.text ?? "sent")
|
? Response(ok: true, message: rpcResult.text ?? "sent")
|
||||||
: Response(ok: false, message: rpcResult.error ?? "failed to send")
|
: Response(ok: false, message: rpcResult.error ?? "failed to send")
|
||||||
|
|
||||||
case let .canvasShow(session, path):
|
case let .canvasShow(session, path, placement):
|
||||||
guard canvasEnabled else {
|
guard canvasEnabled else {
|
||||||
return Response(ok: false, message: "Canvas disabled by user")
|
return Response(ok: false, message: "Canvas disabled by user")
|
||||||
}
|
}
|
||||||
do {
|
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)
|
return Response(ok: true, message: dir)
|
||||||
} catch {
|
} catch {
|
||||||
return Response(ok: false, message: error.localizedDescription)
|
return Response(ok: false, message: error.localizedDescription)
|
||||||
|
|
@ -100,12 +100,12 @@ enum ControlRequestHandler {
|
||||||
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
|
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
|
||||||
return Response(ok: true)
|
return Response(ok: true)
|
||||||
|
|
||||||
case let .canvasGoto(session, path):
|
case let .canvasGoto(session, path, placement):
|
||||||
guard canvasEnabled else {
|
guard canvasEnabled else {
|
||||||
return Response(ok: false, message: "Canvas disabled by user")
|
return Response(ok: false, message: "Canvas disabled by user")
|
||||||
}
|
}
|
||||||
do {
|
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)
|
return Response(ok: true)
|
||||||
} catch {
|
} catch {
|
||||||
return Response(ok: false, message: error.localizedDescription)
|
return Response(ok: false, message: error.localizedDescription)
|
||||||
|
|
|
||||||
|
|
@ -171,15 +171,26 @@ struct ClawdisCLI {
|
||||||
case "show":
|
case "show":
|
||||||
var session = "main"
|
var session = "main"
|
||||||
var path: String?
|
var path: String?
|
||||||
|
var x: Double?
|
||||||
|
var y: Double?
|
||||||
|
var width: Double?
|
||||||
|
var height: Double?
|
||||||
while !args.isEmpty {
|
while !args.isEmpty {
|
||||||
let arg = args.removeFirst()
|
let arg = args.removeFirst()
|
||||||
switch arg {
|
switch arg {
|
||||||
case "--session": session = args.popFirst() ?? session
|
case "--session": session = args.popFirst() ?? session
|
||||||
case "--path": path = args.popFirst()
|
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
|
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":
|
case "hide":
|
||||||
var session = "main"
|
var session = "main"
|
||||||
|
|
@ -195,16 +206,27 @@ struct ClawdisCLI {
|
||||||
case "goto":
|
case "goto":
|
||||||
var session = "main"
|
var session = "main"
|
||||||
var path: String?
|
var path: String?
|
||||||
|
var x: Double?
|
||||||
|
var y: Double?
|
||||||
|
var width: Double?
|
||||||
|
var height: Double?
|
||||||
while !args.isEmpty {
|
while !args.isEmpty {
|
||||||
let arg = args.removeFirst()
|
let arg = args.removeFirst()
|
||||||
switch arg {
|
switch arg {
|
||||||
case "--session": session = args.popFirst() ?? session
|
case "--session": session = args.popFirst() ?? session
|
||||||
case "--path": path = args.popFirst()
|
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
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard let path else { throw CLIError.help }
|
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":
|
case "eval":
|
||||||
var session = "main"
|
var session = "main"
|
||||||
|
|
@ -260,8 +282,10 @@ struct ClawdisCLI {
|
||||||
clawdis-mac agent --message <text> [--thinking <low|default|high>]
|
clawdis-mac agent --message <text> [--thinking <low|default|high>]
|
||||||
[--session <key>] [--deliver] [--to <E.164>]
|
[--session <key>] [--deliver] [--to <E.164>]
|
||||||
clawdis-mac canvas show [--session <key>] [--path </...>]
|
clawdis-mac canvas show [--session <key>] [--path </...>]
|
||||||
|
[--x <screenX> --y <screenY>] [--width <w> --height <h>]
|
||||||
clawdis-mac canvas hide [--session <key>]
|
clawdis-mac canvas hide [--session <key>]
|
||||||
clawdis-mac canvas goto --path </...> [--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 eval --js <code> [--session <key>]
|
||||||
clawdis-mac canvas snapshot [--out <path>] [--session <key>]
|
clawdis-mac canvas snapshot [--out <path>] [--session <key>]
|
||||||
clawdis-mac --help
|
clawdis-mac --help
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,24 @@ public enum NotificationDelivery: String, Codable, Sendable {
|
||||||
case auto
|
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 {
|
public enum Request: Sendable {
|
||||||
case notify(
|
case notify(
|
||||||
title: String,
|
title: String,
|
||||||
|
|
@ -49,9 +67,9 @@ public enum Request: Sendable {
|
||||||
case status
|
case status
|
||||||
case agent(message: String, thinking: String?, session: String?, deliver: Bool, to: String?)
|
case agent(message: String, thinking: String?, session: String?, deliver: Bool, to: String?)
|
||||||
case rpcStatus
|
case rpcStatus
|
||||||
case canvasShow(session: String, path: String?)
|
case canvasShow(session: String, path: String?, placement: CanvasPlacement?)
|
||||||
case canvasHide(session: String)
|
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 canvasEval(session: String, javaScript: String)
|
||||||
case canvasSnapshot(session: String, outPath: String?)
|
case canvasSnapshot(session: String, outPath: String?)
|
||||||
}
|
}
|
||||||
|
|
@ -85,6 +103,7 @@ extension Request: Codable {
|
||||||
case path
|
case path
|
||||||
case javaScript
|
case javaScript
|
||||||
case outPath
|
case outPath
|
||||||
|
case placement
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum Kind: String, Codable {
|
private enum Kind: String, Codable {
|
||||||
|
|
@ -146,19 +165,21 @@ extension Request: Codable {
|
||||||
case .rpcStatus:
|
case .rpcStatus:
|
||||||
try container.encode(Kind.rpcStatus, forKey: .type)
|
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(Kind.canvasShow, forKey: .type)
|
||||||
try container.encode(session, forKey: .session)
|
try container.encode(session, forKey: .session)
|
||||||
try container.encodeIfPresent(path, forKey: .path)
|
try container.encodeIfPresent(path, forKey: .path)
|
||||||
|
try container.encodeIfPresent(placement, forKey: .placement)
|
||||||
|
|
||||||
case let .canvasHide(session):
|
case let .canvasHide(session):
|
||||||
try container.encode(Kind.canvasHide, forKey: .type)
|
try container.encode(Kind.canvasHide, forKey: .type)
|
||||||
try container.encode(session, forKey: .session)
|
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(Kind.canvasGoto, forKey: .type)
|
||||||
try container.encode(session, forKey: .session)
|
try container.encode(session, forKey: .session)
|
||||||
try container.encode(path, forKey: .path)
|
try container.encode(path, forKey: .path)
|
||||||
|
try container.encodeIfPresent(placement, forKey: .placement)
|
||||||
|
|
||||||
case let .canvasEval(session, javaScript):
|
case let .canvasEval(session, javaScript):
|
||||||
try container.encode(Kind.canvasEval, forKey: .type)
|
try container.encode(Kind.canvasEval, forKey: .type)
|
||||||
|
|
@ -220,7 +241,8 @@ extension Request: Codable {
|
||||||
case .canvasShow:
|
case .canvasShow:
|
||||||
let session = try container.decode(String.self, forKey: .session)
|
let session = try container.decode(String.self, forKey: .session)
|
||||||
let path = try container.decodeIfPresent(String.self, forKey: .path)
|
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:
|
case .canvasHide:
|
||||||
let session = try container.decode(String.self, forKey: .session)
|
let session = try container.decode(String.self, forKey: .session)
|
||||||
|
|
@ -229,7 +251,8 @@ extension Request: Codable {
|
||||||
case .canvasGoto:
|
case .canvasGoto:
|
||||||
let session = try container.decode(String.self, forKey: .session)
|
let session = try container.decode(String.self, forKey: .session)
|
||||||
let path = try container.decode(String.self, forKey: .path)
|
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:
|
case .canvasEval:
|
||||||
let session = try container.decode(String.self, forKey: .session)
|
let session = try container.decode(String.self, forKey: .session)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- Can be shown/hidden at any time by the agent.
|
||||||
- Supports an “anchored” presentation (near the menu bar icon or another anchor rect).
|
- 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**.
|
- 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
|
### 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.
|
- Evaluate JavaScript and optionally return results.
|
||||||
- Query/modify DOM (helpers mirroring “dom query/all/attr/click/type/wait” patterns).
|
- Query/modify DOM (helpers mirroring “dom query/all/attr/click/type/wait” patterns).
|
||||||
- Capture a snapshot image of the current canvas view.
|
- 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.
|
This should be modeled after `WebChatManager`/`WebChatWindowController` but targeting `clawdis-canvas://…` URLs.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue