use std::sync::Arc;
use super::hotkey_input::HotkeyValue;
use crate::{prelude::FluentBuilder as _, *};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum HotkeyCapture {
Ignored,
Cancelled,
Appended,
}
pub struct HotkeyListInputState {
hotkeys: Vec<HotkeyValue>,
recording: bool,
focus_handle: FocusHandle,
}
impl HotkeyListInputState {
pub fn new(cx: &mut Context<Self>) -> Self {
Self {
hotkeys: Vec::new(),
recording: false,
focus_handle: cx.focus_handle(),
}
}
pub fn with_hotkeys(cx: &mut Context<Self>, hotkeys: Vec<HotkeyValue>) -> Self {
Self {
hotkeys,
recording: false,
focus_handle: cx.focus_handle(),
}
}
pub fn hotkeys(&self) -> &[HotkeyValue] {
&self.hotkeys
}
pub fn set_hotkeys(&mut self, hotkeys: Vec<HotkeyValue>, cx: &mut Context<Self>) {
self.hotkeys = hotkeys;
self.recording = false;
cx.notify();
}
pub fn add_hotkey(&mut self, hotkey: HotkeyValue, cx: &mut Context<Self>) -> bool {
let duplicate = self
.hotkeys
.iter()
.any(|existing| existing.key == hotkey.key && existing.modifiers == hotkey.modifiers);
if duplicate {
return false;
}
self.hotkeys.push(hotkey);
cx.notify();
true
}
pub fn remove_hotkey(&mut self, index: usize, cx: &mut Context<Self>) -> bool {
if index >= self.hotkeys.len() {
return false;
}
self.hotkeys.remove(index);
cx.notify();
true
}
pub fn clear(&mut self, cx: &mut Context<Self>) {
self.hotkeys.clear();
self.recording = false;
cx.notify();
}
pub fn is_recording(&self) -> bool {
self.recording
}
pub fn start_recording(&mut self, cx: &mut Context<Self>) {
self.recording = true;
cx.notify();
}
pub fn stop_recording(&mut self, cx: &mut Context<Self>) {
self.recording = false;
cx.notify();
}
pub fn capture_keystroke(
&mut self,
keystroke: &Keystroke,
cx: &mut Context<Self>,
) -> HotkeyCapture {
if !self.recording {
return HotkeyCapture::Ignored;
}
if keystroke.key.as_str() == "escape" {
self.stop_recording(cx);
return HotkeyCapture::Cancelled;
}
if let Some(hotkey) = HotkeyValue::from_keystroke(keystroke) {
self.add_hotkey(hotkey, cx);
self.recording = false;
cx.notify();
return HotkeyCapture::Appended;
}
HotkeyCapture::Ignored
}
}
impl Focusable for HotkeyListInputState {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for HotkeyListInputState {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
}
}
#[derive(IntoElement)]
pub struct HotkeyListInput {
state: Entity<HotkeyListInputState>,
placeholder: SharedString,
recording_text: SharedString,
disabled: bool,
on_change: Option<Arc<dyn Fn(Vec<HotkeyValue>, &mut Window, &mut App) + Send + Sync>>,
style: StyleRefinement,
}
impl HotkeyListInput {
pub fn new(state: Entity<HotkeyListInputState>) -> Self {
Self {
state,
placeholder: "Click to add".into(),
recording_text: "Press a key...".into(),
disabled: false,
on_change: None,
style: StyleRefinement::default(),
}
}
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn recording_text(mut self, text: impl Into<SharedString>) -> Self {
self.recording_text = text.into();
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn on_change(
mut self,
handler: impl Fn(Vec<HotkeyValue>, &mut Window, &mut App) + Send + Sync + 'static,
) -> Self {
self.on_change = Some(Arc::new(handler));
self
}
}
impl Styled for HotkeyListInput {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for HotkeyListInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let user_style = self.style;
let state_data = self.state.read(cx);
let hotkeys = state_data.hotkeys.clone();
let recording = state_data.recording;
let focus_handle = state_data.focus_handle(cx);
let is_focused = focus_handle.is_focused(window);
let state_for_add = self.state.clone();
let state_for_keydown = self.state.clone();
let on_change_for_keydown = self.on_change.clone();
let on_change_for_remove = self.on_change.clone();
let border_color = if recording {
theme.tokens.primary
} else if is_focused {
theme.tokens.ring
} else {
theme.tokens.input
};
let focus_ring = BoxShadow::new(px(0.0), px(0.0), *theme.tokens.ring).blur_radius(px(6.0));
let recording_ring = BoxShadow {
offset: point(px(0.0), px(0.0)),
blur_radius: px(0.0),
spread_radius: px(3.0),
color: theme.tokens.primary.opacity(0.3),
inset: false,
};
let mut root = div();
root.style().refine(&user_style);
root.child(
div()
.id(("hotkey-list-input", self.state.entity_id()))
.track_focus(&focus_handle.tab_index(0).tab_stop(true))
.min_h(px(40.0))
.px(px(12.0))
.py(px(6.0))
.flex()
.flex_wrap()
.items_center()
.gap(px(6.0))
.bg(theme.tokens.background)
.border_1()
.border_color(border_color)
.rounded(theme.radius)
.font_family(theme.mono_font_family.clone())
.text_size(px(14.0))
.when(self.disabled, |d| d.opacity(0.5).cursor_not_allowed())
.when(!self.disabled, |d| d.cursor_pointer())
.when(is_focused && !recording, |d| d.shadow(vec![focus_ring]))
.when(recording, |d| {
d.shadow(vec![recording_ring])
.border_color(theme.tokens.primary)
})
.when(!self.disabled, |d| {
d.on_click(move |_, window, cx| {
state_for_add.update(cx, |state, cx| {
if !state.recording {
state.start_recording(cx);
}
});
window.refresh();
})
})
.when(!self.disabled, |d| {
d.on_key_down(move |event, window, cx| {
if event.is_held {
return;
}
let (outcome, hotkeys) = state_for_keydown.update(cx, |state, cx| {
let outcome = state.capture_keystroke(&event.keystroke, cx);
(outcome, state.hotkeys.clone())
});
match outcome {
HotkeyCapture::Ignored => {}
HotkeyCapture::Cancelled => cx.stop_propagation(),
HotkeyCapture::Appended => {
if let Some(ref handler) = on_change_for_keydown {
handler(hotkeys, window, cx);
}
cx.stop_propagation();
}
}
})
})
.children(hotkeys.iter().enumerate().map(|(ix, hotkey)| {
let state_for_remove = self.state.clone();
let on_change_for_remove = on_change_for_remove.clone();
let label: SharedString = hotkey.format_display().into();
h_flex()
.id(("hotkey-list-chip", ix))
.items_center()
.gap(px(4.0))
.px(px(8.0))
.py(px(4.0))
.rounded(px(4.0))
.bg(theme.tokens.muted)
.text_color(theme.tokens.foreground)
.child(label)
.child(
div()
.id(("hotkey-list-remove", ix))
.text_color(theme.tokens.muted_foreground)
.hover(|s| s.text_color(theme.tokens.foreground))
.on_click(move |_, window, cx| {
let hotkeys = state_for_remove.update(cx, |state, cx| {
state.remove_hotkey(ix, cx);
state.hotkeys.clone()
});
if let Some(ref handler) = on_change_for_remove {
handler(hotkeys, window, cx);
}
cx.stop_propagation();
})
.child("×"),
)
.into_any_element()
}))
.when(hotkeys.is_empty() && !recording, |d| {
d.child(
div()
.text_color(theme.tokens.muted_foreground)
.child(self.placeholder.clone()),
)
})
.when(recording, |d| {
d.child(
div()
.text_color(theme.tokens.muted_foreground)
.opacity(0.7)
.child(self.recording_text.clone()),
)
}),
)
}
}
#[cfg(test)]
mod tests {
use super::super::hotkey_input::HotkeyValue;
use super::{HotkeyCapture, HotkeyListInput, HotkeyListInputState};
use crate::{AppContext as _, Context, Entity, Keystroke, Render, Window};
struct Probe {
state: Entity<HotkeyListInputState>,
}
impl Render for Probe {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl crate::IntoElement {
HotkeyListInput::new(self.state.clone())
}
}
#[rgpui::test]
fn list_add_remove_clear(cx: &mut crate::TestAppContext) {
let state = cx.new(HotkeyListInputState::new);
cx.update(|cx| {
state.update(cx, |state, cx| {
let a = HotkeyValue::new("a", Default::default());
let b = HotkeyValue::new("b", Default::default());
assert!(state.add_hotkey(a.clone(), cx));
assert!(!state.add_hotkey(a, cx));
assert!(state.add_hotkey(b, cx));
assert_eq!(state.hotkeys().len(), 2);
assert!(!state.remove_hotkey(7, cx));
assert!(state.remove_hotkey(0, cx));
assert_eq!(state.hotkeys()[0].key, "b");
state.clear(cx);
assert!(state.hotkeys().is_empty());
});
});
}
#[rgpui::test]
fn capture_appends_and_escape_only_stops(cx: &mut crate::TestAppContext) {
let state = cx.new(HotkeyListInputState::new);
cx.update(|cx| {
state.update(cx, |state, cx| {
assert_eq!(
state.capture_keystroke(&Keystroke::parse("ctrl-a").unwrap(), cx),
HotkeyCapture::Ignored
);
state.start_recording(cx);
assert_eq!(
state.capture_keystroke(&Keystroke::parse("ctrl-a").unwrap(), cx),
HotkeyCapture::Appended
);
assert!(!state.is_recording());
assert_eq!(state.hotkeys().len(), 1);
state.start_recording(cx);
assert_eq!(
state.capture_keystroke(&Keystroke::parse("escape").unwrap(), cx),
HotkeyCapture::Cancelled
);
assert!(!state.is_recording());
assert_eq!(state.hotkeys().len(), 1);
state.set_hotkeys(Vec::new(), cx);
assert!(state.hotkeys().is_empty());
});
});
}
#[rgpui::test]
fn renders_chips_and_recording_without_panic(cx: &mut crate::TestAppContext) {
let state = cx.new(HotkeyListInputState::new);
cx.update(|cx| {
state.update(cx, |state, cx| {
state.set_hotkeys(vec![HotkeyValue::new("a", Default::default())], cx);
state.start_recording(cx);
});
});
let (_view, cx) = cx.add_window_view(|_, _| Probe {
state: state.clone(),
});
cx.update(|window, cx| {
_ = window.draw(cx);
});
}
}