ZIm/crates/language_tools/src/key_context_view.rs
Dino e0fc767c11
Display case-sensitive keybindings for vim commands (#24322)
This Pull Request tackles the issue outline in #14287 by changing the
way `KeyBinding`s for vim mode are displayed in the command palette.
It's worth pointing out that this whole thing was pretty much
implemented by Conrad Irwin during a pairing session, I just tried to
clean up some other changes introduced for a different issue, while
improving some comments.

Here's a quick list of the changes introduced:

- Update `KeyBinding` with a new `vim_mode` field to determine whether
the keybinding should be displayed in vim mode.
- Update the way `KeyBinding` is rendered, so as to detect if the
keybinding is for vim mode, if it is, only display keys in uppercase if
they require the shift key.
- Introduce a new global state – `VimStyle(bool)` - use to determine
whether `vim_mode` should be enabled or disabled when creating a new
`KeyBinding` struct. This global state is automatically set by the `vim`
crate whenever vim mode is enabled or disabled.
- Since the app's context is now required when building a `KeyBinding` ,
update a lot of callers to correctly pass this context.

And before and after screenshots, for comparison:

| before | after |
|--------|-------|
| <img width="1050" alt="SCR-20250205-tyeq"
src="https://github.com/user-attachments/assets/e577206d-2a3d-4e06-a96f-a98899cc15c0"
/> | <img width="1050" alt="SCR-20250205-tylh"
src="https://github.com/user-attachments/assets/ebbf70a9-e838-4d32-aee5-0ffde94d65fb"
/> |

Closes #14287 

Release Notes:

- Fix rendering of vim commands to preserve case sensitivity

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-02-14 22:03:59 -07:00

300 lines
12 KiB
Rust

use gpui::{
actions, Action, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable,
KeyBindingContextPredicate, KeyContext, Keystroke, MouseButton, Render, Subscription,
};
use itertools::Itertools;
use serde_json::json;
use settings::get_key_equivalents;
use ui::{
div, h_flex, px, v_flex, ButtonCommon, Clickable, Context, FluentBuilder, InteractiveElement,
Label, LabelCommon, LabelSize, ParentElement, SharedString, StatefulInteractiveElement, Styled,
Window,
};
use ui::{Button, ButtonStyle};
use workspace::{Item, SplitDirection, Workspace};
actions!(debug, [OpenKeyContextView]);
pub fn init(cx: &mut App) {
cx.observe_new(|workspace: &mut Workspace, _, _| {
workspace.register_action(|workspace, _: &OpenKeyContextView, window, cx| {
let key_context_view = cx.new(|cx| KeyContextView::new(window, cx));
workspace.split_item(
SplitDirection::Right,
Box::new(key_context_view),
window,
cx,
)
});
})
.detach();
}
struct KeyContextView {
pending_keystrokes: Option<Vec<Keystroke>>,
last_keystrokes: Option<SharedString>,
last_possibilities: Vec<(SharedString, SharedString, Option<bool>)>,
context_stack: Vec<KeyContext>,
focus_handle: FocusHandle,
_subscriptions: [Subscription; 2],
}
impl KeyContextView {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let sub1 = cx.observe_keystrokes(|this, e, window, cx| {
let mut pending = this.pending_keystrokes.take().unwrap_or_default();
pending.push(e.keystroke.clone());
let mut possibilities = cx.all_bindings_for_input(&pending);
possibilities.reverse();
this.context_stack = window.context_stack();
this.last_keystrokes = Some(
json!(pending.iter().map(|p| p.unparse()).join(" "))
.to_string()
.into(),
);
this.last_possibilities = possibilities
.into_iter()
.map(|binding| {
let match_state = if let Some(predicate) = binding.predicate() {
if this.matches(&predicate) {
if this.action_matches(&e.action, binding.action()) {
Some(true)
} else {
Some(false)
}
} else {
None
}
} else {
if this.action_matches(&e.action, binding.action()) {
Some(true)
} else {
Some(false)
}
};
let predicate = if let Some(predicate) = binding.predicate() {
format!("{}", predicate)
} else {
"".to_string()
};
let mut name = binding.action().name();
if name == "zed::NoAction" {
name = "(null)"
}
(
name.to_owned().into(),
json!(predicate).to_string().into(),
match_state,
)
})
.collect();
});
let sub2 = cx.observe_pending_input(window, |this, window, cx| {
this.pending_keystrokes = window
.pending_input_keystrokes()
.map(|k| k.iter().cloned().collect());
if this.pending_keystrokes.is_some() {
this.last_keystrokes.take();
}
cx.notify();
});
Self {
context_stack: Vec::new(),
pending_keystrokes: None,
last_keystrokes: None,
last_possibilities: Vec::new(),
focus_handle: cx.focus_handle(),
_subscriptions: [sub1, sub2],
}
}
}
impl EventEmitter<()> for KeyContextView {}
impl Focusable for KeyContextView {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl KeyContextView {
fn set_context_stack(&mut self, stack: Vec<KeyContext>, cx: &mut Context<Self>) {
self.context_stack = stack;
cx.notify()
}
fn matches(&self, predicate: &KeyBindingContextPredicate) -> bool {
let mut stack = self.context_stack.clone();
while !stack.is_empty() {
if predicate.eval(&stack) {
return true;
}
stack.pop();
}
false
}
fn action_matches(&self, a: &Option<Box<dyn Action>>, b: &dyn Action) -> bool {
if let Some(last_action) = a {
last_action.partial_eq(b)
} else {
b.name() == "zed::NoAction"
}
}
}
impl Item for KeyContextView {
type Event = ();
fn to_item_events(_: &Self::Event, _: impl FnMut(workspace::item::ItemEvent)) {}
fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option<SharedString> {
Some("Keyboard Context".into())
}
fn telemetry_event_text(&self) -> Option<&'static str> {
None
}
fn clone_on_split(
&self,
_workspace_id: Option<workspace::WorkspaceId>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Entity<Self>>
where
Self: Sized,
{
Some(cx.new(|cx| KeyContextView::new(window, cx)))
}
}
impl Render for KeyContextView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
use itertools::Itertools;
let key_equivalents = get_key_equivalents(cx.keyboard_layout());
v_flex()
.id("key-context-view")
.overflow_scroll()
.size_full()
.max_h_full()
.pt_4()
.pl_4()
.track_focus(&self.focus_handle)
.key_context("KeyContextView")
.on_mouse_up_out(
MouseButton::Left,
cx.listener(|this, _, window, cx| {
this.last_keystrokes.take();
this.set_context_stack(window.context_stack(), cx);
}),
)
.on_mouse_up_out(
MouseButton::Right,
cx.listener(|_, _, window, cx| {
cx.defer_in(window, |this, window, cx| {
this.last_keystrokes.take();
this.set_context_stack(window.context_stack(), cx);
});
}),
)
.child(Label::new("Keyboard Context").size(LabelSize::Large))
.child(Label::new("This view lets you determine the current context stack for creating custom key bindings in Zed. When a keyboard shortcut is triggered, it also shows all the possible contexts it could have triggered in, and which one matched."))
.child(
h_flex()
.mt_4()
.gap_4()
.child(
Button::new("default", "Open Documentation")
.style(ButtonStyle::Filled)
.on_click(|_, _, cx| cx.open_url("https://zed.dev/docs/key-bindings")),
)
.child(
Button::new("default", "View default keymap")
.style(ButtonStyle::Filled)
.key_binding(ui::KeyBinding::for_action(
&zed_actions::OpenDefaultKeymap,
window,
cx
))
.on_click(|_, window, cx| {
window.dispatch_action(workspace::SplitRight.boxed_clone(), cx);
window.dispatch_action(zed_actions::OpenDefaultKeymap.boxed_clone(), cx);
}),
)
.child(
Button::new("default", "Edit your keymap")
.style(ButtonStyle::Filled)
.key_binding(ui::KeyBinding::for_action(&zed_actions::OpenKeymap, window, cx))
.on_click(|_, window, cx| {
window.dispatch_action(workspace::SplitRight.boxed_clone(), cx);
window.dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx);
}),
),
)
.child(
Label::new("Current Context Stack")
.size(LabelSize::Large)
.mt_8(),
)
.children({
window.context_stack().iter().enumerate().map(|(i, context)| {
let primary = context.primary().map(|e| e.key.clone()).unwrap_or_default();
let secondary = context
.secondary()
.map(|e| {
if let Some(value) = e.value.as_ref() {
format!("{}={}", e.key, value)
} else {
e.key.to_string()
}
})
.join(" ");
Label::new(format!("{} {}", primary, secondary)).ml(px(12. * (i + 1) as f32))
})
})
.child(Label::new("Last Keystroke").mt_4().size(LabelSize::Large))
.when_some(self.pending_keystrokes.as_ref(), |el, keystrokes| {
el.child(
Label::new(format!(
"Waiting for more input: {}",
keystrokes.iter().map(|k| k.unparse()).join(" ")
))
.ml(px(12.)),
)
})
.when_some(self.last_keystrokes.as_ref(), |el, keystrokes| {
el.child(Label::new(format!("Typed: {}", keystrokes)).ml_4())
.children(
self.last_possibilities
.iter()
.map(|(name, predicate, state)| {
let (text, color) = match state {
Some(true) => ("(match)", ui::Color::Success),
Some(false) => ("(low precedence)", ui::Color::Hint),
None => ("(no match)", ui::Color::Error),
};
h_flex()
.gap_2()
.ml_8()
.child(div().min_w(px(200.)).child(Label::new(name.clone())))
.child(Label::new(predicate.clone()))
.child(Label::new(text).color(color))
}),
)
})
.when_some(key_equivalents, |el, key_equivalents| {
el.child(Label::new("Key Equivalents").mt_4().size(LabelSize::Large))
.child(Label::new("Shortcuts defined using some characters have been remapped so that shortcuts can be typed without holding option."))
.children(
key_equivalents
.iter()
.sorted()
.map(|(key, equivalent)| {
Label::new(format!("cmd-{} => cmd-{}", key, equivalent)).ml_8()
}),
)
})
}
}