Detailed changes
@@ -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
-
- - 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: 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"
-
- - name: Run tests
- uses: ./.github/actions/run_tests
-
- - name: Build collab
- run: cargo build -p collab
-
- - 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
+ 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: Check spelling
+ run: |
+ if ! which typos > /dev/null; then
+ cargo install typos-cli
+ fi
+ typos
+
+ - name: Run style checks
+ uses: ./.github/actions/check_style
+
+ 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: Build collab
+ run: cargo build -p collab
+
+ - 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
+ 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 mut context_stack: SmallVec<[KeyContext; 4]> = SmallVec::new();
+ for node_id in dispatch_path {
+ let node = self.node(*node_id);
- 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();
+ 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();
-
- 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);
- }
- }
+ 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);
+
+ 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);
+ }
- 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);
+ // 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);
- context_stack.pop();
+ 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 !actions.is_empty() {
- self.clear_pending_keystrokes();
- }
+ if !bindings.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));
- return;
+ 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() {
@@ -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"}}
@@ -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"}}