Skip to content

Commit

Permalink
Add Nvim user commands (#22)
Browse files Browse the repository at this point in the history
  • Loading branch information
mickael-menu authored Feb 20, 2023
1 parent 5f9d8c5 commit d745f68
Show file tree
Hide file tree
Showing 15 changed files with 228 additions and 84 deletions.
File renamed without changes.
4 changes: 2 additions & 2 deletions .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.

4 changes: 2 additions & 2 deletions .github/ISSUE_TEMPLATE/support.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** (<kbd>⌃⌥⌘.</kbd>) 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** (<kbd>⌃⌥⌘⎋</kbd>) kills Neovim and restarts the synchronization. This might be useful if you get stuck.
* **Keys Passthrough** (<kbd>⌃⌥⌘.</kbd>) lets Xcode handle key events until you press <kbd>⎋</kbd>. You **must** use this when renaming a symbol with Xcode's refactor tools, otherwise the buffer will get messed up.
* **Reset ShadowVim** (<kbd>⌃⌥⌘⎋</kbd>) 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 <kbd>⎋</kbd>.
* `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 <kbd>⌘S</kbd> 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 (<kbd>⎋</kbd>) 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. <kbd>⌘P</kbd> is a good candidate, who needs to print their code anyway?

### Completion placeholders

You cannot jump between placeholders in a completion snippet using <kbd>tab</kbd>, 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 /<LT>#.\{-}#><CR>gn
nmap cap /<LT>#.\{-}#><CR>cgn
```

## Attributions

Expand Down
144 changes: 92 additions & 52 deletions Sources/Mediator/App/AppMediator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [:]
Expand All @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -407,25 +464,8 @@ extension AppMediator: BufferMediatorDelegate {

extension AppMediator: NvimDelegate {
public func nvim(_ nvim: Nvim, didRequest method: String, with data: [Value]) -> Result<Value, Error>? {
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) {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Mediator/Buffer/BufferMediator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
15 changes: 13 additions & 2 deletions Sources/Mediator/MediatorContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -62,7 +71,9 @@ public final class MediatorContainer {
keyResolver: keyResolver
),
logger: logger?.domain("app"),
bufferMediatorFactory: bufferMediator
bufferMediatorFactory: bufferMediator,
enableKeysPassthrough: enableKeysPassthrough,
resetShadowVim: resetShadowVim
)
}

Expand Down
File renamed without changes.
File renamed without changes.
41 changes: 40 additions & 1 deletion Sources/Nvim/Nvim.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable> = []

init(
Expand Down Expand Up @@ -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<Void> {
precondition(userCommands[command] == nil)
userCommands[command] = action
return api
.command("command! -nargs=\(args.rawValue) \(command) call rpcrequest(1, '\(command)', <f-args>)")
.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 {
Expand All @@ -89,6 +120,14 @@ extension Nvim: RPCSessionDelegate {
}

func session(_ session: RPCSession, didReceiveRequest method: String, with params: [Value]) -> Result<Value, Error>? {
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)
}
}
}
4 changes: 3 additions & 1 deletion Sources/Nvim/NvimContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
6 changes: 2 additions & 4 deletions Sources/Nvim/NvimProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public final class NvimProcess {
let output = Pipe()
let process = Process()
process.executableURL = executableURL

process.arguments = [
"nvim",
"--headless",
Expand All @@ -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', '<args>')",
]

process.standardInput = input
process.standardOutput = output
process.loadEnvironment()
Expand Down
Loading

0 comments on commit d745f68

Please sign in to comment.