feat(mac): allow Canvas placement and resizing
This commit is contained in:
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user