Add support for mapping `jk` to escape in vim mode.

This changes the behaviour of the keymatches when there are pending
matches.

Before: Even if there was a pending match, any complete matches would be
triggered and the pending state lost.

After: If there is a pending match, any complete matches are delayed by
1s, or until more keys are typed.

Release Notes:

- Added support for mapping `jk` in vim mode
([#2378](https://github.com/zed-industries/community/issues/2378)),
([#176](https://github.com/zed-industries/community/issues/176))
This commit is contained in:
Conrad Irwin 2024-01-22 11:12:43 -07:00 committed by GitHub
commit 72cb865108
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 575 additions and 607 deletions

View File

@ -1,149 +1,148 @@
name: CI
on:
push:
branches:
- main
- "v[0-9]+.[0-9]+.x"
tags:
- "v*"
pull_request:
branches:
- "**"
push:
branches:
- main
- "v[0-9]+.[0-9]+.x"
tags:
- "v*"
pull_request:
branches:
- "**"
concurrency:
# Allow only one workflow per any non-`main` branch.
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
cancel-in-progress: true
# Allow only one workflow per any non-`main` branch.
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
jobs:
style:
name: Check formatting, Clippy lints, and spelling
runs-on:
- self-hosted
- test
steps:
- name: Checkout repo
uses: actions/checkout@v3
with:
clean: false
submodules: "recursive"
fetch-depth: 0
style:
name: Check formatting, Clippy lints, and spelling
runs-on:
- self-hosted
- test
steps:
- name: Checkout repo
uses: actions/checkout@v3
with:
clean: false
submodules: "recursive"
fetch-depth: 0
- name: Set up default .cargo/config.toml
run: cp ./.cargo/ci-config.toml ~/.cargo/config.toml
- name: Set up default .cargo/config.toml
run: cp ./.cargo/ci-config.toml ~/.cargo/config.toml
- name: Check spelling
run: |
if ! which typos > /dev/null; then
cargo install typos-cli
fi
typos
- name: Check spelling
run: |
if ! which typos > /dev/null; then
cargo install typos-cli
fi
typos
- name: Run style checks
uses: ./.github/actions/check_style
- name: Run style checks
uses: ./.github/actions/check_style
tests:
name: Run tests
runs-on:
- self-hosted
- test
needs: style
steps:
- name: Checkout repo
uses: actions/checkout@v3
with:
clean: false
submodules: "recursive"
tests:
name: Run tests
runs-on:
- self-hosted
- test
steps:
- name: Checkout repo
uses: actions/checkout@v3
with:
clean: false
submodules: "recursive"
- name: Run tests
uses: ./.github/actions/run_tests
- name: Run tests
uses: ./.github/actions/run_tests
- name: Build collab
run: cargo build -p collab
- name: Build collab
run: cargo build -p collab
- name: Build other binaries
run: cargo build --workspace --bins --all-features
- name: Build other binaries
run: cargo build --workspace --bins --all-features
bundle:
name: Bundle app
runs-on:
- self-hosted
- bundle
if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
needs: tests
bundle:
name: Bundle app
runs-on:
- self-hosted
- bundle
if: ${{ startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
needs: tests
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
steps:
- name: Install Node
uses: actions/setup-node@v3
with:
node-version: "18"
- name: Checkout repo
uses: actions/checkout@v3
with:
clean: false
submodules: "recursive"
- name: Limit target directory size
run: script/clear-target-dir-if-larger-than 100
- name: Determine version and release channel
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
run: |
set -eu
version=$(script/get-crate-version zed)
channel=$(cat crates/zed/RELEASE_CHANNEL)
echo "Publishing version: ${version} on release channel ${channel}"
echo "RELEASE_CHANNEL=${channel}" >> $GITHUB_ENV
expected_tag_name=""
case ${channel} in
stable)
expected_tag_name="v${version}";;
preview)
expected_tag_name="v${version}-pre";;
nightly)
expected_tag_name="v${version}-nightly";;
*)
echo "can't publish a release on channel ${channel}"
exit 1;;
esac
if [[ $GITHUB_REF_NAME != $expected_tag_name ]]; then
echo "invalid release tag ${GITHUB_REF_NAME}. expected ${expected_tag_name}"
exit 1
fi
- name: Generate license file
run: script/generate-licenses
- name: Create app bundle
run: script/bundle
- name: Upload app bundle to workflow run if main branch or specific label
uses: actions/upload-artifact@v3
if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
with:
name: Zed_${{ github.event.pull_request.head.sha || github.sha }}.dmg
path: target/release/Zed.dmg
- uses: softprops/action-gh-release@v1
name: Upload app bundle to release
if: ${{ env.RELEASE_CHANNEL == 'preview' || env.RELEASE_CHANNEL == 'stable' }}
with:
draft: true
prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }}
files: target/release/Zed.dmg
body: ""
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
steps:
- name: Install Node
uses: actions/setup-node@v3
with:
node-version: "18"
- name: Checkout repo
uses: actions/checkout@v3
with:
clean: false
submodules: "recursive"
- name: Limit target directory size
run: script/clear-target-dir-if-larger-than 100
- name: Determine version and release channel
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
run: |
set -eu
version=$(script/get-crate-version zed)
channel=$(cat crates/zed/RELEASE_CHANNEL)
echo "Publishing version: ${version} on release channel ${channel}"
echo "RELEASE_CHANNEL=${channel}" >> $GITHUB_ENV
expected_tag_name=""
case ${channel} in
stable)
expected_tag_name="v${version}";;
preview)
expected_tag_name="v${version}-pre";;
nightly)
expected_tag_name="v${version}-nightly";;
*)
echo "can't publish a release on channel ${channel}"
exit 1;;
esac
if [[ $GITHUB_REF_NAME != $expected_tag_name ]]; then
echo "invalid release tag ${GITHUB_REF_NAME}. expected ${expected_tag_name}"
exit 1
fi
- name: Generate license file
run: script/generate-licenses
- name: Create app bundle
run: script/bundle
- name: Upload app bundle to workflow run if main branch or specific label
uses: actions/upload-artifact@v3
if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
with:
name: Zed_${{ github.event.pull_request.head.sha || github.sha }}.dmg
path: target/release/Zed.dmg
- uses: softprops/action-gh-release@v1
name: Upload app bundle to release
if: ${{ env.RELEASE_CHANNEL == 'preview' || env.RELEASE_CHANNEL == 'stable' }}
with:
draft: true
prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }}
files: target/release/Zed.dmg
body: ""
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -34,6 +34,7 @@ use std::{
atomic::{AtomicBool, Ordering::SeqCst},
Arc,
},
time::Duration,
};
use unindent::Unindent as _;
@ -5945,3 +5946,26 @@ async fn test_right_click_menu_behind_collab_panel(cx: &mut TestAppContext) {
});
assert!(cx.debug_bounds("MENU_ITEM-Close").is_some());
}
#[gpui::test]
async fn test_cmd_k_left(cx: &mut TestAppContext) {
let client = TestServer::start1(cx).await;
let (workspace, cx) = client.build_test_workspace(cx).await;
cx.simulate_keystrokes("cmd-n");
workspace.update(cx, |workspace, cx| {
assert!(workspace.items(cx).collect::<Vec<_>>().len() == 1);
});
cx.simulate_keystrokes("cmd-k left");
workspace.update(cx, |workspace, cx| {
assert!(workspace.items(cx).collect::<Vec<_>>().len() == 2);
});
cx.simulate_keystrokes("cmd-k");
// sleep for longer than the timeout in keyboard shortcut handling
// to verify that it doesn't fire in this case.
cx.executor().advance_clock(Duration::from_secs(2));
cx.simulate_keystrokes("left");
workspace.update(cx, |workspace, cx| {
assert!(workspace.items(cx).collect::<Vec<_>>().len() == 3);
});
}

View File

@ -127,6 +127,11 @@ impl TestServer {
(client_a, client_b, channel_id)
}
pub async fn start1<'a>(cx: &'a mut TestAppContext) -> TestClient {
let mut server = Self::start(cx.executor().clone()).await;
server.create_client(cx, "user_a").await
}
pub async fn reset(&self) {
self.app_state.db.reset();
let epoch = self

View File

@ -1,6 +1,57 @@
/// KeyDispatch is where GPUI deals with binding actions to key events.
///
/// The key pieces to making a key binding work are to define an action,
/// implement a method that takes that action as a type parameter,
/// and then to register the action during render on a focused node
/// with a keymap context:
///
/// ```rust
/// actions!(editor,[Undo, Redo]);;
///
/// impl Editor {
/// fn undo(&mut self, _: &Undo, _cx: &mut ViewContext<Self>) { ... }
/// fn redo(&mut self, _: &Redo, _cx: &mut ViewContext<Self>) { ... }
/// }
///
/// impl Render for Editor {
/// fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
/// div()
/// .track_focus(&self.focus_handle)
/// .keymap_context("Editor")
/// .on_action(cx.listener(Editor::undo))
/// .on_action(cx.listener(Editor::redo))
/// ...
/// }
/// }
///```
///
/// The keybindings themselves are managed independently by calling cx.bind_keys().
/// (Though mostly when developing Zed itself, you just need to add a new line to
/// assets/keymaps/default.json).
///
/// ```rust
/// cx.bind_keys([
/// KeyBinding::new("cmd-z", Editor::undo, Some("Editor")),
/// KeyBinding::new("cmd-shift-z", Editor::redo, Some("Editor")),
/// ])
/// ```
///
/// With all of this in place, GPUI will ensure that if you have an Editor that contains
/// the focus, hitting cmd-z will Undo.
///
/// In real apps, it is a little more complicated than this, because typically you have
/// several nested views that each register keyboard handlers. In this case action matching
/// bubbles up from the bottom. For example in Zed, the Workspace is the top-level view, which contains Pane's, which contain Editors. If there are conflicting keybindings defined
/// then the Editor's bindings take precedence over the Pane's bindings, which take precedence over the Workspace.
///
/// In GPUI, keybindings are not limited to just single keystrokes, you can define
/// sequences by separating the keys with a space:
///
/// KeyBinding::new("cmd-k left", pane::SplitLeft, Some("Pane"))
///
use crate::{
Action, ActionRegistry, DispatchPhase, ElementContext, EntityId, FocusId, KeyBinding,
KeyContext, KeyMatch, Keymap, Keystroke, KeystrokeMatcher, WindowContext,
KeyContext, Keymap, KeymatchResult, Keystroke, KeystrokeMatcher, WindowContext,
};
use collections::FxHashMap;
use parking_lot::Mutex;
@ -272,30 +323,51 @@ impl DispatchTree {
.collect()
}
// dispatch_key pushses the next keystroke into any key binding matchers.
// any matching bindings are returned in the order that they should be dispatched:
// * First by length of binding (so if you have a binding for "b" and "ab", the "ab" binding fires first)
// * Secondly by depth in the tree (so if Editor has a binding for "b" and workspace a
// binding for "b", the Editor action fires first).
pub fn dispatch_key(
&mut self,
keystroke: &Keystroke,
context: &[KeyContext],
) -> Vec<Box<dyn Action>> {
if !self.keystroke_matchers.contains_key(context) {
let keystroke_contexts = context.iter().cloned().collect();
self.keystroke_matchers.insert(
keystroke_contexts,
KeystrokeMatcher::new(self.keymap.clone()),
);
}
dispatch_path: &SmallVec<[DispatchNodeId; 32]>,
) -> KeymatchResult {
let mut bindings = SmallVec::<[KeyBinding; 1]>::new();
let mut pending = false;
let keystroke_matcher = self.keystroke_matchers.get_mut(context).unwrap();
if let KeyMatch::Some(actions) = keystroke_matcher.match_keystroke(keystroke, context) {
// Clear all pending keystrokes when an action has been found.
for keystroke_matcher in self.keystroke_matchers.values_mut() {
keystroke_matcher.clear_pending();
let mut context_stack: SmallVec<[KeyContext; 4]> = SmallVec::new();
for node_id in dispatch_path {
let node = self.node(*node_id);
if let Some(context) = node.context.clone() {
context_stack.push(context);
}
actions
} else {
vec![]
}
while !context_stack.is_empty() {
let keystroke_matcher = self
.keystroke_matchers
.entry(context_stack.clone())
.or_insert_with(|| KeystrokeMatcher::new(self.keymap.clone()));
let result = keystroke_matcher.match_keystroke(keystroke, &context_stack);
pending = result.pending || pending;
for new_binding in result.bindings {
match bindings
.iter()
.position(|el| el.keystrokes.len() < new_binding.keystrokes.len())
{
Some(idx) => {
bindings.insert(idx, new_binding);
}
None => bindings.push(new_binding),
}
}
context_stack.pop();
}
KeymatchResult { bindings, pending }
}
pub fn has_pending_keystrokes(&self) -> bool {

View File

@ -50,7 +50,7 @@ impl KeyBinding {
if self.keystrokes.as_ref().starts_with(pending_keystrokes) {
// If the binding is completed, push it onto the matches list
if self.keystrokes.as_ref().len() == pending_keystrokes.len() {
KeyMatch::Some(vec![self.action.boxed_clone()])
KeyMatch::Matched
} else {
KeyMatch::Pending
}

View File

@ -1,5 +1,6 @@
use crate::{Action, KeyContext, Keymap, KeymapVersion, Keystroke};
use crate::{KeyBinding, KeyContext, Keymap, KeymapVersion, Keystroke};
use parking_lot::Mutex;
use smallvec::SmallVec;
use std::sync::Arc;
pub(crate) struct KeystrokeMatcher {
@ -8,6 +9,11 @@ pub(crate) struct KeystrokeMatcher {
keymap_version: KeymapVersion,
}
pub struct KeymatchResult {
pub bindings: SmallVec<[KeyBinding; 1]>,
pub pending: bool,
}
impl KeystrokeMatcher {
pub fn new(keymap: Arc<Mutex<Keymap>>) -> Self {
let keymap_version = keymap.lock().version();
@ -18,10 +24,6 @@ impl KeystrokeMatcher {
}
}
pub fn clear_pending(&mut self) {
self.pending_keystrokes.clear();
}
pub fn has_pending_keystrokes(&self) -> bool {
!self.pending_keystrokes.is_empty()
}
@ -39,7 +41,7 @@ impl KeystrokeMatcher {
&mut self,
keystroke: &Keystroke,
context_stack: &[KeyContext],
) -> KeyMatch {
) -> KeymatchResult {
let keymap = self.keymap.lock();
// Clear pending keystrokes if the keymap has changed since the last matched keystroke.
if keymap.version() != self.keymap_version {
@ -48,7 +50,7 @@ impl KeystrokeMatcher {
}
let mut pending_key = None;
let mut found_actions = Vec::new();
let mut bindings = SmallVec::new();
for binding in keymap.bindings().rev() {
if !keymap.binding_enabled(binding, context_stack) {
@ -58,8 +60,8 @@ impl KeystrokeMatcher {
for candidate in keystroke.match_candidates() {
self.pending_keystrokes.push(candidate.clone());
match binding.match_keystrokes(&self.pending_keystrokes) {
KeyMatch::Some(mut actions) => {
found_actions.append(&mut actions);
KeyMatch::Matched => {
bindings.push(binding.clone());
}
KeyMatch::Pending => {
pending_key.get_or_insert(candidate);
@ -70,16 +72,21 @@ impl KeystrokeMatcher {
}
}
if !found_actions.is_empty() {
self.pending_keystrokes.clear();
return KeyMatch::Some(found_actions);
} else if let Some(pending_key) = pending_key {
if bindings.len() == 0 && pending_key.is_none() && self.pending_keystrokes.len() > 0 {
drop(keymap);
self.pending_keystrokes.remove(0);
return self.match_keystroke(keystroke, context_stack);
}
let pending = if let Some(pending_key) = pending_key {
self.pending_keystrokes.push(pending_key);
KeyMatch::Pending
true
} else {
self.pending_keystrokes.clear();
KeyMatch::None
}
false
};
KeymatchResult { bindings, pending }
}
}
@ -87,386 +94,9 @@ impl KeystrokeMatcher {
/// - KeyMatch::None => No match is valid for this key given any pending keystrokes.
/// - KeyMatch::Pending => There exist bindings that is still waiting for more keys.
/// - KeyMatch::Some(matches) => One or more bindings have received the necessary key presses.
#[derive(Debug)]
#[derive(Debug, PartialEq)]
pub enum KeyMatch {
None,
Pending,
Some(Vec<Box<dyn Action>>),
}
impl KeyMatch {
/// Returns true if the match is complete.
pub fn is_some(&self) -> bool {
matches!(self, KeyMatch::Some(_))
}
/// Get the matches if the match is complete.
pub fn matches(self) -> Option<Vec<Box<dyn Action>>> {
match self {
KeyMatch::Some(matches) => Some(matches),
_ => None,
}
}
}
impl PartialEq for KeyMatch {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(KeyMatch::None, KeyMatch::None) => true,
(KeyMatch::Pending, KeyMatch::Pending) => true,
(KeyMatch::Some(a), KeyMatch::Some(b)) => {
if a.len() != b.len() {
return false;
}
for (a, b) in a.iter().zip(b.iter()) {
if !a.partial_eq(b.as_ref()) {
return false;
}
}
true
}
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use serde_derive::Deserialize;
use super::*;
use crate::{self as gpui, KeyBindingContextPredicate, Modifiers};
use crate::{actions, KeyBinding};
#[test]
fn test_keymap_and_view_ordering() {
actions!(test, [EditorAction, ProjectPanelAction]);
let mut editor = KeyContext::default();
editor.add("Editor");
let mut project_panel = KeyContext::default();
project_panel.add("ProjectPanel");
// Editor 'deeper' in than project panel
let dispatch_path = vec![project_panel, editor];
// But editor actions 'higher' up in keymap
let keymap = Keymap::new(vec![
KeyBinding::new("left", EditorAction, Some("Editor")),
KeyBinding::new("left", ProjectPanelAction, Some("ProjectPanel")),
]);
let mut matcher = KeystrokeMatcher::new(Arc::new(Mutex::new(keymap)));
let matches = matcher
.match_keystroke(&Keystroke::parse("left").unwrap(), &dispatch_path)
.matches()
.unwrap();
assert!(matches[0].partial_eq(&EditorAction));
assert!(matches.get(1).is_none());
}
#[test]
fn test_multi_keystroke_match() {
actions!(test, [B, AB, C, D, DA, E, EF]);
let mut context1 = KeyContext::default();
context1.add("1");
let mut context2 = KeyContext::default();
context2.add("2");
let dispatch_path = vec![context2, context1];
let keymap = Keymap::new(vec![
KeyBinding::new("a b", AB, Some("1")),
KeyBinding::new("b", B, Some("2")),
KeyBinding::new("c", C, Some("2")),
KeyBinding::new("d", D, Some("1")),
KeyBinding::new("d", D, Some("2")),
KeyBinding::new("d a", DA, Some("2")),
]);
let mut matcher = KeystrokeMatcher::new(Arc::new(Mutex::new(keymap)));
// Binding with pending prefix always takes precedence
assert_eq!(
matcher.match_keystroke(&Keystroke::parse("a").unwrap(), &dispatch_path),
KeyMatch::Pending,
);
// B alone doesn't match because a was pending, so AB is returned instead
assert_eq!(
matcher.match_keystroke(&Keystroke::parse("b").unwrap(), &dispatch_path),
KeyMatch::Some(vec![Box::new(AB)]),
);
assert!(!matcher.has_pending_keystrokes());
// Without an a prefix, B is dispatched like expected
assert_eq!(
matcher.match_keystroke(&Keystroke::parse("b").unwrap(), &dispatch_path[0..1]),
KeyMatch::Some(vec![Box::new(B)]),
);
assert!(!matcher.has_pending_keystrokes());
// If a is prefixed, C will not be dispatched because there
// was a pending binding for it
assert_eq!(
matcher.match_keystroke(&Keystroke::parse("a").unwrap(), &dispatch_path),
KeyMatch::Pending,
);
assert_eq!(
matcher.match_keystroke(&Keystroke::parse("c").unwrap(), &dispatch_path),
KeyMatch::None,
);
assert!(!matcher.has_pending_keystrokes());
// If a single keystroke matches multiple bindings in the tree
// only one of them is returned.
assert_eq!(
matcher.match_keystroke(&Keystroke::parse("d").unwrap(), &dispatch_path),
KeyMatch::Some(vec![Box::new(D)]),
);
}
#[test]
fn test_keystroke_parsing() {
assert_eq!(
Keystroke::parse("ctrl-p").unwrap(),
Keystroke {
key: "p".into(),
modifiers: Modifiers {
control: true,
alt: false,
shift: false,
command: false,
function: false,
},
ime_key: None,
}
);
assert_eq!(
Keystroke::parse("alt-shift-down").unwrap(),
Keystroke {
key: "down".into(),
modifiers: Modifiers {
control: false,
alt: true,
shift: true,
command: false,
function: false,
},
ime_key: None,
}
);
assert_eq!(
Keystroke::parse("shift-cmd--").unwrap(),
Keystroke {
key: "-".into(),
modifiers: Modifiers {
control: false,
alt: false,
shift: true,
command: true,
function: false,
},
ime_key: None,
}
);
}
#[test]
fn test_context_predicate_parsing() {
use KeyBindingContextPredicate::*;
assert_eq!(
KeyBindingContextPredicate::parse("a && (b == c || d != e)").unwrap(),
And(
Box::new(Identifier("a".into())),
Box::new(Or(
Box::new(Equal("b".into(), "c".into())),
Box::new(NotEqual("d".into(), "e".into())),
))
)
);
assert_eq!(
KeyBindingContextPredicate::parse("!a").unwrap(),
Not(Box::new(Identifier("a".into())),)
);
}
#[test]
fn test_context_predicate_eval() {
let predicate = KeyBindingContextPredicate::parse("a && b || c == d").unwrap();
let mut context = KeyContext::default();
context.add("a");
assert!(!predicate.eval(&[context]));
let mut context = KeyContext::default();
context.add("a");
context.add("b");
assert!(predicate.eval(&[context]));
let mut context = KeyContext::default();
context.add("a");
context.set("c", "x");
assert!(!predicate.eval(&[context]));
let mut context = KeyContext::default();
context.add("a");
context.set("c", "d");
assert!(predicate.eval(&[context]));
let predicate = KeyBindingContextPredicate::parse("!a").unwrap();
assert!(predicate.eval(&[KeyContext::default()]));
}
#[test]
fn test_context_child_predicate_eval() {
let predicate = KeyBindingContextPredicate::parse("a && b > c").unwrap();
let contexts = [
context_set(&["a", "b"]),
context_set(&["c", "d"]), // match this context
context_set(&["e", "f"]),
];
assert!(!predicate.eval(&contexts[..=0]));
assert!(predicate.eval(&contexts[..=1]));
assert!(!predicate.eval(&contexts[..=2]));
let predicate = KeyBindingContextPredicate::parse("a && b > c && !d > e").unwrap();
let contexts = [
context_set(&["a", "b"]),
context_set(&["c", "d"]),
context_set(&["e"]),
context_set(&["a", "b"]),
context_set(&["c"]),
context_set(&["e"]), // only match this context
context_set(&["f"]),
];
assert!(!predicate.eval(&contexts[..=0]));
assert!(!predicate.eval(&contexts[..=1]));
assert!(!predicate.eval(&contexts[..=2]));
assert!(!predicate.eval(&contexts[..=3]));
assert!(!predicate.eval(&contexts[..=4]));
assert!(predicate.eval(&contexts[..=5]));
assert!(!predicate.eval(&contexts[..=6]));
fn context_set(names: &[&str]) -> KeyContext {
let mut keymap = KeyContext::default();
names.iter().for_each(|name| keymap.add(name.to_string()));
keymap
}
}
#[test]
fn test_matcher() {
#[derive(Clone, Deserialize, PartialEq, Eq, Debug)]
pub struct A(pub String);
impl_actions!(test, [A]);
actions!(test, [B, Ab, Dollar, Quote, Ess, Backtick]);
#[derive(Clone, Debug, Eq, PartialEq)]
struct ActionArg {
a: &'static str,
}
let keymap = Keymap::new(vec![
KeyBinding::new("a", A("x".to_string()), Some("a")),
KeyBinding::new("b", B, Some("a")),
KeyBinding::new("a b", Ab, Some("a || b")),
KeyBinding::new("$", Dollar, Some("a")),
KeyBinding::new("\"", Quote, Some("a")),
KeyBinding::new("alt-s", Ess, Some("a")),
KeyBinding::new("ctrl-`", Backtick, Some("a")),
]);
let mut context_a = KeyContext::default();
context_a.add("a");
let mut context_b = KeyContext::default();
context_b.add("b");
let mut matcher = KeystrokeMatcher::new(Arc::new(Mutex::new(keymap)));
// Basic match
assert_eq!(
matcher.match_keystroke(&Keystroke::parse("a").unwrap(), &[context_a.clone()]),
KeyMatch::Some(vec![Box::new(A("x".to_string()))])
);
matcher.clear_pending();
// Multi-keystroke match
assert_eq!(
matcher.match_keystroke(&Keystroke::parse("a").unwrap(), &[context_b.clone()]),
KeyMatch::Pending
);
assert_eq!(
matcher.match_keystroke(&Keystroke::parse("b").unwrap(), &[context_b.clone()]),
KeyMatch::Some(vec![Box::new(Ab)])
);
matcher.clear_pending();
// Failed matches don't interfere with matching subsequent keys
assert_eq!(
matcher.match_keystroke(&Keystroke::parse("x").unwrap(), &[context_a.clone()]),
KeyMatch::None
);
assert_eq!(
matcher.match_keystroke(&Keystroke::parse("a").unwrap(), &[context_a.clone()]),
KeyMatch::Some(vec![Box::new(A("x".to_string()))])
);
matcher.clear_pending();
let mut context_c = KeyContext::default();
context_c.add("c");
assert_eq!(
matcher.match_keystroke(
&Keystroke::parse("a").unwrap(),
&[context_c.clone(), context_b.clone()]
),
KeyMatch::Pending
);
assert_eq!(
matcher.match_keystroke(&Keystroke::parse("b").unwrap(), &[context_b.clone()]),
KeyMatch::Some(vec![Box::new(Ab)])
);
// handle Czech $ (option + 4 key)
assert_eq!(
matcher.match_keystroke(&Keystroke::parse("alt-ç->$").unwrap(), &[context_a.clone()]),
KeyMatch::Some(vec![Box::new(Dollar)])
);
// handle Brazilian quote (quote key then space key)
assert_eq!(
matcher.match_keystroke(
&Keystroke::parse("space->\"").unwrap(),
&[context_a.clone()]
),
KeyMatch::Some(vec![Box::new(Quote)])
);
// handle ctrl+` on a brazilian keyboard
assert_eq!(
matcher.match_keystroke(&Keystroke::parse("ctrl-->`").unwrap(), &[context_a.clone()]),
KeyMatch::Some(vec![Box::new(Backtick)])
);
// handle alt-s on a US keyboard
assert_eq!(
matcher.match_keystroke(&Keystroke::parse("alt-s->ß").unwrap(), &[context_a.clone()]),
KeyMatch::Some(vec![Box::new(Ess)])
);
}
Matched,
}

View File

@ -359,7 +359,7 @@ impl PlatformInputHandler {
self.cx
.update(|cx| {
self.handler
.replace_text_in_range(replacement_range, text, cx)
.replace_text_in_range(replacement_range, text, cx);
})
.ok();
}
@ -392,6 +392,13 @@ impl PlatformInputHandler {
.ok()
.flatten()
}
pub(crate) fn flush_pending_input(&mut self, input: &str, cx: &mut WindowContext) {
let Some(range) = self.handler.selected_text_range(cx) else {
return;
};
self.handler.replace_text_in_range(Some(range), &input, cx);
}
}
/// Zed's interface for handling text input from the platform's IME system

View File

@ -30,24 +30,26 @@ impl Keystroke {
pub(crate) fn match_candidates(&self) -> SmallVec<[Keystroke; 2]> {
let mut possibilities = SmallVec::new();
match self.ime_key.as_ref() {
None => possibilities.push(self.clone()),
Some(ime_key) => {
possibilities.push(Keystroke {
modifiers: Modifiers {
control: self.modifiers.control,
alt: false,
shift: false,
command: false,
function: false,
},
key: ime_key.to_string(),
ime_key: None,
});
if ime_key != &self.key {
possibilities.push(Keystroke {
modifiers: Modifiers {
control: self.modifiers.control,
alt: false,
shift: false,
command: false,
function: false,
},
key: ime_key.to_string(),
ime_key: None,
});
}
possibilities.push(Keystroke {
ime_key: None,
..self.clone()
});
}
None => possibilities.push(self.clone()),
}
possibilities
}

View File

@ -1542,9 +1542,7 @@ extern "C" fn insert_text(this: &Object, _: Sel, text: id, replacement_range: NS
replacement_range,
text: text.to_string(),
});
if text.to_string().to_ascii_lowercase() != pending_key_down.0.keystroke.key {
pending_key_down.0.keystroke.ime_key = Some(text.to_string());
}
pending_key_down.0.keystroke.ime_key = Some(text.to_string());
window_state.lock().pending_key_down = Some(pending_key_down);
}
}

View File

@ -96,7 +96,19 @@ impl TestWindow {
result
}
pub fn simulate_keystroke(&mut self, keystroke: Keystroke, is_held: bool) {
pub fn simulate_keystroke(&mut self, mut keystroke: Keystroke, is_held: bool) {
if keystroke.ime_key.is_none()
&& !keystroke.modifiers.command
&& !keystroke.modifiers.control
&& !keystroke.modifiers.function
{
keystroke.ime_key = Some(if keystroke.modifiers.shift {
keystroke.key.to_ascii_uppercase().clone()
} else {
keystroke.key.clone()
})
}
if self.simulate_input(PlatformInput::KeyDown(KeyDownEvent {
keystroke: keystroke.clone(),
is_held,
@ -112,8 +124,9 @@ impl TestWindow {
);
};
drop(lock);
let text = keystroke.ime_key.unwrap_or(keystroke.key);
input_handler.replace_text_in_range(None, &text);
if let Some(text) = keystroke.ime_key.as_ref() {
input_handler.replace_text_in_range(None, &text);
}
self.0.lock().input_handler = Some(input_handler);
}

View File

@ -2,11 +2,11 @@ use crate::{
px, size, transparent_black, Action, AnyDrag, AnyView, AppContext, Arena, AsyncWindowContext,
AvailableSpace, Bounds, Context, Corners, CursorStyle, DispatchActionListener, DispatchNodeId,
DispatchTree, DisplayId, Edges, Effect, Entity, EntityId, EventEmitter, FileDropEvent, Flatten,
GlobalElementId, Hsla, KeyBinding, KeyContext, KeyDownEvent, KeystrokeEvent, Model,
ModelContext, Modifiers, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas,
PlatformDisplay, PlatformInput, PlatformWindow, Point, PromptLevel, Render, ScaledPixels,
SharedString, Size, SubscriberSet, Subscription, TaffyLayoutEngine, Task, View, VisualContext,
WeakView, WindowBounds, WindowOptions,
GlobalElementId, Hsla, KeyBinding, KeyContext, KeyDownEvent, KeyMatch, KeymatchResult,
Keystroke, KeystrokeEvent, Model, ModelContext, Modifiers, MouseButton, MouseMoveEvent,
MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformWindow, Point,
PromptLevel, Render, ScaledPixels, SharedString, Size, SubscriberSet, Subscription,
TaffyLayoutEngine, Task, View, VisualContext, WeakView, WindowBounds, WindowOptions,
};
use anyhow::{anyhow, Context as _, Result};
use collections::FxHashSet;
@ -33,6 +33,7 @@ use std::{
atomic::{AtomicUsize, Ordering::SeqCst},
Arc,
},
time::Duration,
};
use util::{measure, ResultExt};
@ -273,11 +274,47 @@ pub struct Window {
activation_observers: SubscriberSet<(), AnyObserver>,
pub(crate) focus: Option<FocusId>,
focus_enabled: bool,
pending_input: Option<PendingInput>,
#[cfg(any(test, feature = "test-support"))]
pub(crate) focus_invalidated: bool,
}
#[derive(Default, Debug)]
struct PendingInput {
keystrokes: SmallVec<[Keystroke; 1]>,
bindings: SmallVec<[KeyBinding; 1]>,
focus: Option<FocusId>,
timer: Option<Task<()>>,
}
impl PendingInput {
fn is_noop(&self) -> bool {
self.bindings.is_empty() && (self.keystrokes.iter().all(|k| k.ime_key.is_none()))
}
fn input(&self) -> String {
self.keystrokes
.iter()
.flat_map(|k| k.ime_key.clone())
.collect::<Vec<String>>()
.join("")
}
fn used_by_binding(&self, binding: &KeyBinding) -> bool {
if self.keystrokes.is_empty() {
return true;
}
let keystroke = &self.keystrokes[0];
for candidate in keystroke.match_candidates() {
if binding.match_keystrokes(&[candidate]) == KeyMatch::Pending {
return true;
}
}
false
}
}
pub(crate) struct ElementStateBox {
pub(crate) inner: Box<dyn Any>,
pub(crate) parent_view_id: EntityId,
@ -379,6 +416,7 @@ impl Window {
activation_observers: SubscriberSet::new(),
focus: None,
focus_enabled: true,
pending_input: None,
#[cfg(any(test, feature = "test-support"))]
focus_invalidated: false,
@ -1175,44 +1213,67 @@ impl<'a> WindowContext<'a> {
.dispatch_tree
.dispatch_path(node_id);
let mut actions: Vec<Box<dyn Action>> = Vec::new();
if let Some(key_down_event) = event.downcast_ref::<KeyDownEvent>() {
let KeymatchResult { bindings, pending } = self
.window
.rendered_frame
.dispatch_tree
.dispatch_key(&key_down_event.keystroke, &dispatch_path);
let mut context_stack: SmallVec<[KeyContext; 16]> = SmallVec::new();
for node_id in &dispatch_path {
let node = self.window.rendered_frame.dispatch_tree.node(*node_id);
if let Some(context) = node.context.clone() {
context_stack.push(context);
}
}
for node_id in dispatch_path.iter().rev() {
// Match keystrokes
let node = self.window.rendered_frame.dispatch_tree.node(*node_id);
if node.context.is_some() {
if let Some(key_down_event) = event.downcast_ref::<KeyDownEvent>() {
let mut new_actions = self
.window
.rendered_frame
.dispatch_tree
.dispatch_key(&key_down_event.keystroke, &context_stack);
actions.append(&mut new_actions);
if pending {
let mut currently_pending = self.window.pending_input.take().unwrap_or_default();
if currently_pending.focus.is_some() && currently_pending.focus != self.window.focus
{
currently_pending = PendingInput::default();
}
currently_pending.focus = self.window.focus;
currently_pending
.keystrokes
.push(key_down_event.keystroke.clone());
for binding in bindings {
currently_pending.bindings.push(binding);
}
context_stack.pop();
}
}
// for vim compatibility, we also should check "is input handler enabled"
if !currently_pending.is_noop() {
currently_pending.timer = Some(self.spawn(|mut cx| async move {
cx.background_executor.timer(Duration::from_secs(1)).await;
cx.update(move |cx| {
cx.clear_pending_keystrokes();
let Some(currently_pending) = cx.window.pending_input.take() else {
return;
};
cx.replay_pending_input(currently_pending)
})
.log_err();
}));
} else {
currently_pending.timer = None;
}
self.window.pending_input = Some(currently_pending);
if !actions.is_empty() {
self.clear_pending_keystrokes();
}
self.propagate_event = true;
for action in actions {
self.dispatch_action_on_node(node_id, action.boxed_clone());
if !self.propagate_event {
self.dispatch_keystroke_observers(event, Some(action));
self.propagate_event = false;
return;
} else if let Some(currently_pending) = self.window.pending_input.take() {
if bindings
.iter()
.all(|binding| !currently_pending.used_by_binding(&binding))
{
self.replay_pending_input(currently_pending)
}
}
if !bindings.is_empty() {
self.clear_pending_keystrokes();
}
self.propagate_event = true;
for binding in bindings {
self.dispatch_action_on_node(node_id, binding.action.boxed_clone());
if !self.propagate_event {
self.dispatch_keystroke_observers(event, Some(binding.action));
return;
}
}
}
@ -1255,6 +1316,40 @@ impl<'a> WindowContext<'a> {
.has_pending_keystrokes()
}
fn replay_pending_input(&mut self, currently_pending: PendingInput) {
let node_id = self
.window
.focus
.and_then(|focus_id| {
self.window
.rendered_frame
.dispatch_tree
.focusable_node_id(focus_id)
})
.unwrap_or_else(|| self.window.rendered_frame.dispatch_tree.root_node_id());
if self.window.focus != currently_pending.focus {
return;
}
let input = currently_pending.input();
self.propagate_event = true;
for binding in currently_pending.bindings {
self.dispatch_action_on_node(node_id, binding.action.boxed_clone());
if !self.propagate_event {
return;
}
}
if !input.is_empty() {
if let Some(mut input_handler) = self.window.platform_window.take_input_handler() {
input_handler.flush_pending_input(&input, self);
self.window.platform_window.set_input_handler(input_handler)
}
}
}
fn dispatch_action_on_node(&mut self, node_id: DispatchNodeId, action: Box<dyn Action>) {
let dispatch_path = self
.window

View File

@ -73,9 +73,9 @@ pub(crate) struct Up {
#[derive(Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct Down {
pub(crate) struct Down {
#[serde(default)]
display_lines: bool,
pub(crate) display_lines: bool,
}
#[derive(Clone, Deserialize, PartialEq)]

View File

@ -3,8 +3,11 @@ mod neovim_backed_test_context;
mod neovim_connection;
mod vim_test_context;
use std::time::Duration;
use command_palette::CommandPalette;
use editor::DisplayPoint;
use gpui::KeyBinding;
pub use neovim_backed_binding_test_context::*;
pub use neovim_backed_test_context::*;
pub use vim_test_context::*;
@ -12,7 +15,7 @@ pub use vim_test_context::*;
use indoc::indoc;
use search::BufferSearchBar;
use crate::{state::Mode, ModeIndicator};
use crate::{insert::NormalBefore, motion, state::Mode, ModeIndicator};
#[gpui::test]
async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
@ -774,3 +777,73 @@ async fn test_select_all_issue_2170(cx: &mut gpui::TestAppContext) {
Mode::Visual,
);
}
#[gpui::test]
async fn test_jk(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.update(|cx| {
cx.bind_keys([KeyBinding::new(
"j k",
NormalBefore,
Some("vim_mode == insert"),
)])
});
cx.neovim.exec("imap jk <esc>").await;
cx.set_shared_state("ˇhello").await;
cx.simulate_shared_keystrokes(["i", "j", "o", "j", "k"])
.await;
cx.assert_shared_state("jˇohello").await;
}
#[gpui::test]
async fn test_jk_delay(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.update(|cx| {
cx.bind_keys([KeyBinding::new(
"j k",
NormalBefore,
Some("vim_mode == insert"),
)])
});
cx.set_state("ˇhello", Mode::Normal);
cx.simulate_keystrokes(["i", "j"]);
cx.executor().advance_clock(Duration::from_millis(500));
cx.run_until_parked();
cx.assert_state("ˇhello", Mode::Insert);
cx.executor().advance_clock(Duration::from_millis(500));
cx.run_until_parked();
cx.assert_state("jˇhello", Mode::Insert);
cx.simulate_keystrokes(["k", "j", "k"]);
cx.assert_state("jˇkhello", Mode::Normal);
}
#[gpui::test]
async fn test_comma_w(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.update(|cx| {
cx.bind_keys([KeyBinding::new(
", w",
motion::Down {
display_lines: false,
},
Some("vim_mode == normal"),
)])
});
cx.neovim.exec("map ,w j").await;
cx.set_shared_state("ˇhello hello\nhello hello").await;
cx.simulate_shared_keystrokes(["f", "o", ";", ",", "w"])
.await;
cx.assert_shared_state("hello hello\nhello hellˇo").await;
cx.set_shared_state("ˇhello hello\nhello hello").await;
cx.simulate_shared_keystrokes(["f", "o", ";", ",", "i"])
.await;
cx.assert_shared_state("hellˇo hello\nhello hello").await;
cx.assert_shared_mode(Mode::Insert).await;
}

View File

@ -52,7 +52,7 @@ pub struct NeovimBackedTestContext {
// Lookup for exempted assertions. Keyed by the insertion text, and with a value indicating which
// bindings are exempted. If None, all bindings are ignored for that insertion text.
exemptions: HashMap<String, Option<HashSet<String>>>,
neovim: NeovimConnection,
pub(crate) neovim: NeovimConnection,
last_set_state: Option<String>,
recent_keystrokes: Vec<String>,

View File

@ -42,6 +42,7 @@ pub enum NeovimData {
Key(String),
Get { state: String, mode: Option<Mode> },
ReadRegister { name: char, value: String },
Exec { command: String },
SetOption { value: String },
}
@ -269,6 +270,32 @@ impl NeovimConnection {
);
}
#[cfg(feature = "neovim")]
pub async fn exec(&mut self, value: &str) {
self.nvim
.command_output(format!("{}", value).as_str())
.await
.unwrap();
self.data.push_back(NeovimData::Exec {
command: value.to_string(),
})
}
#[cfg(not(feature = "neovim"))]
pub async fn exec(&mut self, value: &str) {
if let Some(NeovimData::Get { .. }) = self.data.front() {
self.data.pop_front();
};
assert_eq!(
self.data.pop_front(),
Some(NeovimData::Exec {
command: value.to_string(),
}),
"operation does not match recorded script. re-record with --features=neovim"
);
}
#[cfg(not(feature = "neovim"))]
pub async fn read_register(&mut self, register: char) -> String {
if let Some(NeovimData::Get { .. }) = self.data.front() {

View File

@ -0,0 +1,15 @@
{"Exec":{"command":"map ,w j"}}
{"Put":{"state":"ˇhello hello\nhello hello"}}
{"Key":"f"}
{"Key":"o"}
{"Key":";"}
{"Key":","}
{"Key":"w"}
{"Get":{"state":"hello hello\nhello hellˇo","mode":"Normal"}}
{"Put":{"state":"ˇhello hello\nhello hello"}}
{"Key":"f"}
{"Key":"o"}
{"Key":";"}
{"Key":","}
{"Key":"i"}
{"Get":{"state":"hellˇo hello\nhello hello","mode":"Insert"}}

View File

@ -0,0 +1,8 @@
{"Exec":{"command":"imap jk <esc>"}}
{"Put":{"state":"ˇhello"}}
{"Key":"i"}
{"Key":"j"}
{"Key":"o"}
{"Key":"j"}
{"Key":"k"}
{"Get":{"state":"jˇohello","mode":"Normal"}}