From d745f682d41c2d4f4137a1b7f8a533d51a74f1b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Mon, 20 Feb 2023 20:38:03 +0100 Subject: [PATCH] Add Nvim user commands (#22) --- .../DISCUSSION_TEMPLATE/{q-a.yml => help.yml} | 0 .github/ISSUE_TEMPLATE/feature_request.yml | 4 +- .github/ISSUE_TEMPLATE/support.yml | 4 +- README.md | 35 ++++- Sources/Mediator/App/AppMediator.swift | 144 +++++++++++------- Sources/Mediator/Buffer/BufferMediator.swift | 2 +- Sources/Mediator/MediatorContainer.swift | 15 +- Sources/Nvim/{ => API}/Types.swift | 0 Sources/Nvim/{ => API}/Value.swift | 0 Sources/Nvim/Nvim.swift | 41 ++++- Sources/Nvim/NvimContainer.swift | 4 +- Sources/Nvim/NvimProcess.swift | 6 +- Sources/ShadowVim/Container.swift | 32 ++-- Sources/ShadowVim/ShadowVim.swift | 13 +- Sources/Toolkit/Input/EventSource.swift | 12 +- 15 files changed, 228 insertions(+), 84 deletions(-) rename .github/DISCUSSION_TEMPLATE/{q-a.yml => help.yml} (100%) rename Sources/Nvim/{ => API}/Types.swift (100%) rename Sources/Nvim/{ => API}/Value.swift (100%) diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yml b/.github/DISCUSSION_TEMPLATE/help.yml similarity index 100% rename from .github/DISCUSSION_TEMPLATE/q-a.yml rename to .github/DISCUSSION_TEMPLATE/help.yml diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 8692fd2..94a81e8 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -4,7 +4,7 @@ body: - type: checkboxes id: checks attributes: - label: If you have an idea, create a discussion + label: If you have an idea, open a discussion options: - - label: I understand and will [create a new discussion](https://github.com/mickael-menu/ShadowVim/discussions/new?category=ideas) instead of an issue. + - label: I will [create a new discussion](https://github.com/mickael-menu/ShadowVim/discussions/new?category=ideas) instead of an issue. diff --git a/.github/ISSUE_TEMPLATE/support.yml b/.github/ISSUE_TEMPLATE/support.yml index 5f0bbf1..4a2fb03 100644 --- a/.github/ISSUE_TEMPLATE/support.yml +++ b/.github/ISSUE_TEMPLATE/support.yml @@ -8,6 +8,6 @@ body: - type: checkboxes id: checks attributes: - label: If you need help, create a discussion instead of an issue + label: If you need help, open a discussion options: - - label: I understand and will [create a new Q&A discussion](https://github.com/mickael-menu/ShadowVim/discussions/new?category=q-a). + - label: I will [create a new discussion](https://github.com/mickael-menu/ShadowVim/discussions/new?category=help) instead of an issue. diff --git a/README.md b/README.md index caec367..f4ff67f 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,39 @@ As `SVPressKeys` is not recursive, this is fine. ShadowVim adds a new menu bar icon (🅽) with a couple of useful features which can also be triggered with global hotkeys: -* **Keys Passthrough** (⌃⌥⌘.) disables temporarily the Neovim synchronization. You **must** use this when renaming a symbol with Xcode's refactor tools, otherwise the buffer will get messed up. -* **Reset ShadowVim** (⌃⌥⌘⎋) kills Neovim and restarts the synchronization. This might be useful if you get stuck. +* **Keys Passthrough** (⌃⌥⌘.) lets Xcode handle key events until you press ⎋. You **must** use this when renaming a symbol with Xcode's refactor tools, otherwise the buffer will get messed up. +* **Reset ShadowVim** (⌃⌥⌘⎋) kills Neovim and resets the synchronization. This might be useful if you get stuck. + +### Neovim user commands + +The following commands are available in your bindings when Neovim is run by ShadowVim. + +* `SVPressKeys` triggers a keyboard shortcut in Xcode. The syntax is the same as Neovim's key bindings, e.g. `SVPressKeys D-s` to save the current file. +* `SVEnableKeysPassthrough` switches on the Keys Passthrough mode, which lets Xcode handle key events until you press ⎋. +* `SVReset` kills Neovim and resets the synchronization. This might be useful if you get stuck. +* `SVSynchronizeUI` requests Xcode to reset the current file to the state of the Neovim buffer. You should not need to call this manually. +* `SVSynchronizeNvim` requests Neovim to reset the current buffer to the state of the Xcode file. You should not need to call this manually. + +## Tips and tricks + +### Don't use `:w` + +Neovim is in read-only mode, so `:w` won't do anything. Use the usual ⌘S to save your files. + +### Triggering Xcode's completion + +Xcode's completion generally works with ShadowVim. But there are some cases where the pop-up completion does not appear automatically. + +As the default Xcode shortcut to trigger the completion (⎋) is already used in Neovim to go back to the normal mode, you might want to set a different one in Xcode's **Key Bindings** preferences. ⌘P is a good candidate, who needs to print their code anyway? + +### Completion placeholders + +You cannot jump between placeholders in a completion snippet using tab, as it is handled by Neovim. As a workaround, you can use these custom Neovim key bindings to select or modify the next placeholder: + +```viml +nmap gp /#.\{-}#>gn +nmap cap /#.\{-}#>cgn +``` ## Attributions diff --git a/Sources/Mediator/App/AppMediator.swift b/Sources/Mediator/App/AppMediator.swift index b46853a..d134049 100644 --- a/Sources/Mediator/App/AppMediator.swift +++ b/Sources/Mediator/App/AppMediator.swift @@ -81,6 +81,8 @@ public final class AppMediator { private let eventSource: EventSource private let logger: Logger? private let bufferMediatorFactory: BufferMediator.Factory + private let enableKeysPassthrough: () -> Void + private let resetShadowVim: () -> Void private var state: AppState = .stopped private var bufferMediators: [BufferName: BufferMediator] = [:] @@ -92,7 +94,9 @@ public final class AppMediator { buffers: NvimBuffers, eventSource: EventSource, logger: Logger?, - bufferMediatorFactory: @escaping BufferMediator.Factory + bufferMediatorFactory: @escaping BufferMediator.Factory, + enableKeysPassthrough: @escaping () -> Void, + resetShadowVim: @escaping () -> Void ) { self.app = app appElement = AXUIElement.app(app) @@ -101,41 +105,88 @@ public final class AppMediator { self.eventSource = eventSource self.logger = logger self.bufferMediatorFactory = bufferMediatorFactory + self.enableKeysPassthrough = enableKeysPassthrough + self.resetShadowVim = resetShadowVim nvim.delegate = self -// nvim.api.uiAttach( -// width: 1000, -// height: 100, -// options: API.UIOptions( -// extCmdline: true, -// extHlState: true, -// extLineGrid: true, -// extMessages: true, -// extMultigrid: true, -// extPopupMenu: true, -// extTabline: true, -// extTermColors: true -// ) -// ) -// .assertNoFailure() -// .run() -// -// nvim.events.publisher(for: "redraw") -// .assertNoFailure() -// .sink { params in -// for v in params { -// guard -// let a = v.arrayValue, -// let k = a.first?.stringValue, -// k.hasPrefix("msg_") -// else { -// continue -// } -// print(a) -// } -// } -// .store(in: &subscriptions) + setupUserCommands() + + // nvim.api.uiAttach( + // width: 1000, + // height: 100, + // options: API.UIOptions( + // extCmdline: true, + // extHlState: true, + // extLineGrid: true, + // extMessages: true, + // extMultigrid: true, + // extPopupMenu: true, + // extTabline: true, + // extTermColors: true + // ) + // ) + // .forwardErrorToDelegate(of: self) + // .run() + + // nvim.events.publisher(for: "redraw") + // .assertNoFailure() + // .sink { params in + // for v in params { + // guard + // let a = v.arrayValue, + // let k = a.first?.stringValue, + // k.hasPrefix("msg_") + // else { + // continue + // } + // print(a) + // } + // } + // .store(in: &subscriptions) + } + + private func setupUserCommands() { + nvim.add(command: "SVSynchronizeUI") { [weak self] _ in + self?.synchronizeFocusedBuffer(source: .nvim) + return .nil + } + .forwardErrorToDelegate(of: self) + .run() + + nvim.add(command: "SVSynchronizeNvim") { [weak self] _ in + self?.synchronizeFocusedBuffer(source: .ui) + return .nil + } + .forwardErrorToDelegate(of: self) + .run() + + nvim.add(command: "SVPressKeys", args: .one) { [weak self] params in + guard + let self, + params.count == 1, + case let .string(notation) = params[0] + else { + return .bool(false) + } + return .bool(self.pressKeys(notation: notation)) + } + .forwardErrorToDelegate(of: self) + .run() + + nvim.add(command: "SVEnableKeysPassthrough") { [weak self] _ in + self?.enableKeysPassthrough() + return .nil + } + .forwardErrorToDelegate(of: self) + .run() + + nvim.add(command: "SVReset") { [weak self] _ in + self?.resetShadowVim() + return .nil + } + .forwardErrorToDelegate(of: self) + .run() } deinit { @@ -179,6 +230,12 @@ public final class AppMediator { delegate?.appMediatorDidStop(self) } + private func synchronizeFocusedBuffer(source: BufferState.Host) { + if case let .focused(buffer) = state { + buffer.synchronize(source: source) + } + } + // MARK: - Input handling public func handle(_ event: KeyEvent) -> Bool { @@ -407,25 +464,8 @@ extension AppMediator: BufferMediatorDelegate { extension AppMediator: NvimDelegate { public func nvim(_ nvim: Nvim, didRequest method: String, with data: [Value]) -> Result? { - switch method { - case "SVRefresh": - if case let .focused(buffer) = state { - buffer.didRequestRefresh() - } - return .success(.bool(true)) - - case "SVPressKeys": - guard - data.count == 1, - case let .string(notation) = data[0] - else { - return .success(.bool(false)) - } - return .success(.bool(pressKeys(notation: notation))) - - default: - return nil - } + logger?.w("Received unknown RPC request", ["method": method, "data": data]) + return nil } public func nvim(_ nvim: Nvim, didFailWithError error: NvimError) { diff --git a/Sources/Mediator/Buffer/BufferMediator.swift b/Sources/Mediator/Buffer/BufferMediator.swift index 49f0966..717e6b9 100644 --- a/Sources/Mediator/Buffer/BufferMediator.swift +++ b/Sources/Mediator/Buffer/BufferMediator.swift @@ -103,7 +103,7 @@ public final class BufferMediator { .store(in: &subscriptions) } - func didRequestRefresh() { + func synchronize(source: BufferState.Host) { DispatchQueue.main.async { self.on(.didRequestRefresh(source: .nvim)) } diff --git a/Sources/Mediator/MediatorContainer.swift b/Sources/Mediator/MediatorContainer.swift index 84e5095..45e3c9e 100644 --- a/Sources/Mediator/MediatorContainer.swift +++ b/Sources/Mediator/MediatorContainer.swift @@ -25,11 +25,20 @@ import Toolkit public final class MediatorContainer { private let keyResolver: CGKeyResolver private let logger: Logger? + private let enableKeysPassthrough: () -> Void + private let resetShadowVim: () -> Void private let nvimContainer: NvimContainer - public init(keyResolver: CGKeyResolver, logger: Logger?) { + public init( + keyResolver: CGKeyResolver, + logger: Logger?, + enableKeysPassthrough: @escaping () -> Void, + resetShadowVim: @escaping () -> Void + ) { self.keyResolver = keyResolver self.logger = logger?.domain("mediator") + self.enableKeysPassthrough = enableKeysPassthrough + self.resetShadowVim = resetShadowVim nvimContainer = NvimContainer(logger: logger) @@ -62,7 +71,9 @@ public final class MediatorContainer { keyResolver: keyResolver ), logger: logger?.domain("app"), - bufferMediatorFactory: bufferMediator + bufferMediatorFactory: bufferMediator, + enableKeysPassthrough: enableKeysPassthrough, + resetShadowVim: resetShadowVim ) } diff --git a/Sources/Nvim/Types.swift b/Sources/Nvim/API/Types.swift similarity index 100% rename from Sources/Nvim/Types.swift rename to Sources/Nvim/API/Types.swift diff --git a/Sources/Nvim/Value.swift b/Sources/Nvim/API/Value.swift similarity index 100% rename from Sources/Nvim/Value.swift rename to Sources/Nvim/API/Value.swift diff --git a/Sources/Nvim/Nvim.swift b/Sources/Nvim/Nvim.swift index d5c306f..5d22120 100644 --- a/Sources/Nvim/Nvim.swift +++ b/Sources/Nvim/Nvim.swift @@ -39,6 +39,7 @@ public final class Nvim { private let process: NvimProcess private let session: RPCSession private let logger: Logger? + private var userCommands: [String: ([Value]) throws -> Value] = [:] private var subscriptions: Set = [] init( @@ -66,6 +67,36 @@ public final class Nvim { public func stop() { process.stop() } + + /// Adds a new user command executing the given action. + public func add( + command: String, + args: ArgsCardinality = .none, + action: @escaping ([Value]) throws -> Value + ) -> APIAsync { + precondition(userCommands[command] == nil) + userCommands[command] = action + return api + .command("command! -nargs=\(args.rawValue) \(command) call rpcrequest(1, '\(command)', )") + .discardResult() + } +} + +public enum ArgsCardinality: String { + /// No arguments are allowed (the default). + case none = "0" + + /// Exactly one argument is required, it includes spaces. + case one = "1" + + /// Any number of arguments are allowed (0, 1, or many), separated by white space. + case any = "*" + + /// 0 or 1 arguments are allowed. + case noneOrOne = "?" + + /// Arguments must be supplied, but any number are allowed. + case moreThanOne = "+" } extension Nvim: NvimProcessDelegate { @@ -89,6 +120,14 @@ extension Nvim: RPCSessionDelegate { } func session(_ session: RPCSession, didReceiveRequest method: String, with params: [Value]) -> Result? { - delegate?.nvim(self, didRequest: method, with: params) + guard let command = userCommands[method] else { + return delegate?.nvim(self, didRequest: method, with: params) + } + + do { + return try .success(command(params)) + } catch { + return .failure(error) + } } } diff --git a/Sources/Nvim/NvimContainer.swift b/Sources/Nvim/NvimContainer.swift index 9d3e84a..68c1b6c 100644 --- a/Sources/Nvim/NvimContainer.swift +++ b/Sources/Nvim/NvimContainer.swift @@ -26,7 +26,9 @@ public final class NvimContainer { } public func nvim() throws -> Nvim { - let process = try NvimProcess.start(logger: logger?.domain("process")) + let process = try NvimProcess.start( + logger: logger?.domain("process") + ) let session = RPCSession( logger: logger?.domain("rpc"), diff --git a/Sources/Nvim/NvimProcess.swift b/Sources/Nvim/NvimProcess.swift index fa7b33a..b1d9b7a 100644 --- a/Sources/Nvim/NvimProcess.swift +++ b/Sources/Nvim/NvimProcess.swift @@ -37,6 +37,7 @@ public final class NvimProcess { let output = Pipe() let process = Process() process.executableURL = executableURL + process.arguments = [ "nvim", "--headless", @@ -46,11 +47,8 @@ public final class NvimProcess { // "--clean", // Don't load default config and plugins. // Using `--cmd` instead of `-c` makes the statements available in the `init.vim`. "--cmd", "let g:shadowvim = v:true", - // Declare custom user commands for the supported RPC requests. - // FIXME: To be moved into a dedicated script when implementing the Nvim UI protocol - "--cmd", "command SVRefresh call rpcrequest(1, 'SVRefresh')", - "--cmd", "command -nargs=1 SVPressKeys call rpcrequest(1, 'SVPressKeys', '')", ] + process.standardInput = input process.standardOutput = output process.loadEnvironment() diff --git a/Sources/ShadowVim/Container.swift b/Sources/ShadowVim/Container.swift index 0847020..e17e71a 100644 --- a/Sources/ShadowVim/Container.swift +++ b/Sources/ShadowVim/Container.swift @@ -22,14 +22,12 @@ import SauceAdapter import Toolkit final class Container { - let shadowVim: ShadowVim let preferences: Preferences let logger: Logger? - private let mediator: MediatorContainer + private(set) var shadowVim: ShadowVim! + private var mediator: MediatorContainer! init() { - let preferences = UserDefaultsPreferences(defaults: .standard) - let keyResolver = SauceCGKeyResolver() let logger = ReferenceLogger(logger: Debug.isDebugging ? NSLoggerLogger().filter( @@ -50,15 +48,19 @@ final class Container { ) : nil ) + self.logger = logger.domain("app") + + preferences = UserDefaultsPreferences(defaults: .standard) - let mediator = MediatorContainer( + let keyResolver = SauceCGKeyResolver() + + mediator = MediatorContainer( keyResolver: keyResolver, - logger: logger + logger: logger, + enableKeysPassthrough: { [unowned self] in enableKeysPassthrough() }, + resetShadowVim: { [unowned self] in resetShadowVim() } ) - self.logger = logger.domain("app") - self.preferences = preferences - self.mediator = mediator shadowVim = ShadowVim( preferences: preferences, keyResolver: keyResolver, @@ -67,4 +69,16 @@ final class Container { mediatorFactory: mediator.mainMediator ) } + + private func enableKeysPassthrough() { + DispatchQueue.main.async { [self] in + shadowVim.setKeysPassthrough(true) + } + } + + private func resetShadowVim() { + DispatchQueue.main.async { [self] in + shadowVim.reset() + } + } } diff --git a/Sources/ShadowVim/ShadowVim.swift b/Sources/ShadowVim/ShadowVim.swift index 0c637e9..6a7a581 100644 --- a/Sources/ShadowVim/ShadowVim.swift +++ b/Sources/ShadowVim/ShadowVim.swift @@ -74,10 +74,13 @@ class ShadowVim: ObservableObject { stop() } + func setKeysPassthrough(_ enabled: Bool) { + keysPassthrough = enabled + mediator?.didToggleKeysPassthrough(enabled: enabled) + } + func toggleKeysPassthrough() { - keysPassthrough.toggle() - playSound("Pop") - mediator?.didToggleKeysPassthrough(enabled: keysPassthrough) + setKeysPassthrough(!keysPassthrough) } private func start() { @@ -214,6 +217,10 @@ extension ShadowVim: EventTapDelegate { } } + if keysPassthrough, keyCombo == KeyCombo(.escape) { + setKeysPassthrough(false) + } + guard !keysPassthrough, let mediator = mediator, diff --git a/Sources/Toolkit/Input/EventSource.swift b/Sources/Toolkit/Input/EventSource.swift index 1d76cd1..2a88e19 100644 --- a/Sources/Toolkit/Input/EventSource.swift +++ b/Sources/Toolkit/Input/EventSource.swift @@ -48,11 +48,13 @@ public final class EventSource { return false } - let flags = kc.modifiers.cgFlags - downEvent.flags = flags - upEvent.flags = flags - downEvent.postToPid(pid) - upEvent.postToPid(pid) + DispatchQueue.main.async { [self] in + let flags = kc.modifiers.cgFlags + downEvent.flags = flags + upEvent.flags = flags + downEvent.postToPid(pid) + upEvent.postToPid(pid) + } return true }