mirror of
https://github.com/zed-industries/zed.git
synced 2024-11-09 21:26:14 +03:00
jk (#4189)
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:
commit
72cb865108
261
.github/workflows/ci.yml
vendored
261
.github/workflows/ci.yml
vendored
@ -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 }}
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)]
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>,
|
||||
|
@ -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() {
|
||||
|
15
crates/vim/test_data/test_comma_w.json
Normal file
15
crates/vim/test_data/test_comma_w.json
Normal 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"}}
|
8
crates/vim/test_data/test_jk.json
Normal file
8
crates/vim/test_data/test_jk.json
Normal 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"}}
|
Loading…
Reference in New Issue
Block a user