Refine keymap UI design (#34437)

Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <Ben.kunkle@gmail.com>
This commit is contained in:
Danilo Leal 2025-07-15 10:45:59 -03:00 committed by GitHub
parent a65c0b2bff
commit 858e176a1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 397 additions and 211 deletions

2
Cargo.lock generated
View file

@ -14684,6 +14684,7 @@ dependencies = [
"language", "language",
"log", "log",
"menu", "menu",
"notifications",
"paths", "paths",
"project", "project",
"schemars", "schemars",
@ -14695,6 +14696,7 @@ dependencies = [
"tree-sitter-json", "tree-sitter-json",
"tree-sitter-rust", "tree-sitter-rust",
"ui", "ui",
"ui_input",
"util", "util",
"workspace", "workspace",
"workspace-hack", "workspace-hack",

View file

@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 4L10 7L5 10V4Z" fill="black" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 227 B

View file

@ -191,6 +191,7 @@ pub enum IconName {
Play, Play,
PlayAlt, PlayAlt,
PlayBug, PlayBug,
PlayFilled,
Plus, Plus,
PocketKnife, PocketKnife,
Power, Power,

View file

@ -26,6 +26,7 @@ gpui.workspace = true
language.workspace = true language.workspace = true
log.workspace = true log.workspace = true
menu.workspace = true menu.workspace = true
notifications.workspace = true
paths.workspace = true paths.workspace = true
project.workspace = true project.workspace = true
schemars.workspace = true schemars.workspace = true
@ -37,6 +38,7 @@ theme.workspace = true
tree-sitter-json.workspace = true tree-sitter-json.workspace = true
tree-sitter-rust.workspace = true tree-sitter-rust.workspace = true
ui.workspace = true ui.workspace = true
ui_input.workspace = true
util.workspace = true util.workspace = true
workspace-hack.workspace = true workspace-hack.workspace = true
workspace.workspace = true workspace.workspace = true

View file

@ -10,20 +10,22 @@ use feature_flags::FeatureFlagViewExt;
use fs::Fs; use fs::Fs;
use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{ use gpui::{
Action, AnimationExt, AppContext as _, AsyncApp, ClickEvent, Context, DismissEvent, Entity, Action, Animation, AnimationExt, AppContext as _, AsyncApp, ClickEvent, Context, DismissEvent,
EventEmitter, FocusHandle, Focusable, Global, IsZero, KeyContext, KeyDownEvent, Keystroke, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, IsZero, KeyContext,
ModifiersChangedEvent, MouseButton, Point, ScrollStrategy, ScrollWheelEvent, StyledText, KeyDownEvent, Keystroke, ModifiersChangedEvent, MouseButton, Point, ScrollStrategy,
Subscription, WeakEntity, actions, anchored, deferred, div, ScrollWheelEvent, StyledText, Subscription, WeakEntity, actions, anchored, deferred, div,
}; };
use language::{Language, LanguageConfig, ToOffset as _}; use language::{Language, LanguageConfig, ToOffset as _};
use notifications::status_toast::{StatusToast, ToastIcon};
use settings::{BaseKeymap, KeybindSource, KeymapFile, SettingsAssets}; use settings::{BaseKeymap, KeybindSource, KeymapFile, SettingsAssets};
use util::ResultExt; use util::ResultExt;
use ui::{ use ui::{
ActiveTheme as _, App, Banner, BorrowAppContext, ContextMenu, ParentElement as _, Render, ActiveTheme as _, App, Banner, BorrowAppContext, ContextMenu, Modal, ModalFooter, ModalHeader,
SharedString, Styled as _, Tooltip, Window, prelude::*, ParentElement as _, Render, Section, SharedString, Styled as _, Tooltip, Window, prelude::*,
}; };
use ui_input::SingleLineInput;
use workspace::{ use workspace::{
Item, ModalView, SerializableItem, Workspace, notifications::NotifyTaskExt as _, Item, ModalView, SerializableItem, Workspace, notifications::NotifyTaskExt as _,
register_serializable_item, register_serializable_item,
@ -637,7 +639,8 @@ impl KeymapEditor {
"Delete", "Delete",
Box::new(DeleteBinding), Box::new(DeleteBinding),
) )
.action("Copy action", Box::new(CopyAction)) .separator()
.action("Copy Action", Box::new(CopyAction))
.action_disabled_when( .action_disabled_when(
selected_binding_has_no_context, selected_binding_has_no_context,
"Copy Context", "Copy Context",
@ -845,9 +848,15 @@ impl KeymapEditor {
self.search_mode = self.search_mode.invert(); self.search_mode = self.search_mode.invert();
self.update_matches(cx); self.update_matches(cx);
// Update the keystroke editor to turn the `search` bool on
self.keystroke_editor.update(cx, |keystroke_editor, cx| {
keystroke_editor.set_search_mode(self.search_mode == SearchMode::KeyStroke);
cx.notify();
});
match self.search_mode { match self.search_mode {
SearchMode::KeyStroke => { SearchMode::KeyStroke => {
window.focus(&self.keystroke_editor.focus_handle(cx)); window.focus(&self.keystroke_editor.read(cx).recording_focus_handle(cx));
} }
SearchMode::Normal => {} SearchMode::Normal => {}
} }
@ -964,41 +973,60 @@ impl Render for KeymapEditor {
.gap_1() .gap_1()
.bg(theme.colors().editor_background) .bg(theme.colors().editor_background)
.child( .child(
h_flex() v_flex()
.p_2() .p_2()
.gap_1() .gap_2()
.key_context({
let mut context = KeyContext::new_with_defaults();
context.add("BufferSearchBar");
context
})
.child( .child(
div()
.size_full()
.h_8()
.pl_2()
.pr_1()
.py_1()
.border_1()
.border_color(theme.colors().border)
.rounded_lg()
.child(self.filter_editor.clone()),
)
.child(
// TODO: Ask Mikyala if there's a way to get have items be aligned by horizontally
// without embedding a h_flex in another h_flex
h_flex() h_flex()
.gap_2()
.child(
div()
.key_context({
let mut context = KeyContext::new_with_defaults();
context.add("BufferSearchBar");
context
})
.size_full()
.h_8()
.pl_2()
.pr_1()
.py_1()
.border_1()
.border_color(theme.colors().border)
.rounded_lg()
.child(self.filter_editor.clone()),
)
.child(
IconButton::new(
"KeymapEditorToggleFiltersIcon",
IconName::Keyboard,
)
.shape(ui::IconButtonShape::Square)
.tooltip(|window, cx| {
Tooltip::for_action(
"Search by Keystroke",
&ToggleKeystrokeSearch,
window,
cx,
)
})
.toggle_state(matches!(self.search_mode, SearchMode::KeyStroke))
.on_click(|_, window, cx| {
window.dispatch_action(ToggleKeystrokeSearch.boxed_clone(), cx);
}),
)
.when(self.keybinding_conflict_state.any_conflicts(), |this| { .when(self.keybinding_conflict_state.any_conflicts(), |this| {
this.child( this.child(
IconButton::new("KeymapEditorConflictIcon", IconName::Warning) IconButton::new("KeymapEditorConflictIcon", IconName::Warning)
.shape(ui::IconButtonShape::Square)
.tooltip({ .tooltip({
let filter_state = self.filter_state; let filter_state = self.filter_state;
move |window, cx| { move |window, cx| {
Tooltip::for_action( Tooltip::for_action(
match filter_state { match filter_state {
FilterState::All => "Show conflicts", FilterState::All => "Show Conflicts",
FilterState::Conflicts => "Hide conflicts", FilterState::Conflicts => "Hide Conflicts",
}, },
&ToggleConflictFilter, &ToggleConflictFilter,
window, window,
@ -1006,7 +1034,7 @@ impl Render for KeymapEditor {
) )
} }
}) })
.selected_icon_color(Color::Error) .selected_icon_color(Color::Warning)
.toggle_state(matches!( .toggle_state(matches!(
self.filter_state, self.filter_state,
FilterState::Conflicts FilterState::Conflicts
@ -1018,36 +1046,22 @@ impl Render for KeymapEditor {
); );
}), }),
) )
}) }),
.child( )
IconButton::new("KeymapEditorToggleFiltersIcon", IconName::Filter) .when(matches!(self.search_mode, SearchMode::KeyStroke), |this| {
.tooltip(|window, cx| { this.child(
Tooltip::for_action( div()
"Toggle Keystroke Search", .map(|this| {
&ToggleKeystrokeSearch, if self.keybinding_conflict_state.any_conflicts() {
window, this.pr(rems_from_px(54.))
cx, } else {
) this.pr_7()
}) }
.toggle_state(matches!(self.search_mode, SearchMode::KeyStroke)) })
.on_click(|_, window, cx| { .child(self.keystroke_editor.clone()),
window.dispatch_action( )
ToggleKeystrokeSearch.boxed_clone(), }),
cx,
);
}),
),
),
) )
.when(matches!(self.search_mode, SearchMode::KeyStroke), |this| {
this.child(
div()
.child(self.keystroke_editor.clone())
.border_1()
.border_color(theme.colors().border)
.rounded_lg(),
)
})
.child( .child(
Table::new() Table::new()
.interactable(&self.table_interaction_state) .interactable(&self.table_interaction_state)
@ -1063,20 +1077,17 @@ impl Render for KeymapEditor {
.filter_map(|index| { .filter_map(|index| {
let candidate_id = this.matches.get(index)?.candidate_id; let candidate_id = this.matches.get(index)?.candidate_id;
let binding = &this.keybindings[candidate_id]; let binding = &this.keybindings[candidate_id];
let action_name = binding.action_name.clone();
let action = div() let action = div()
.child(binding.action_name.clone())
.id(("keymap action", index)) .id(("keymap action", index))
.child(command_palette::humanize_action_name(&action_name))
.when(!context_menu_deployed, |this| { .when(!context_menu_deployed, |this| {
this.tooltip({ this.tooltip({
let action_name = binding.action_name.clone(); let action_name = binding.action_name.clone();
let action_docs = binding.action_docs; let action_docs = binding.action_docs;
move |_, cx| { move |_, cx| {
let action_tooltip = Tooltip::new( let action_tooltip = Tooltip::new(&action_name);
command_palette::humanize_action_name(
&action_name,
),
);
let action_tooltip = match action_docs { let action_tooltip = match action_docs {
Some(docs) => action_tooltip.meta(docs), Some(docs) => action_tooltip.meta(docs),
None => action_tooltip, None => action_tooltip,
@ -1285,11 +1296,12 @@ struct KeybindingEditorModal {
editing_keybind: ProcessedKeybinding, editing_keybind: ProcessedKeybinding,
editing_keybind_idx: usize, editing_keybind_idx: usize,
keybind_editor: Entity<KeystrokeInput>, keybind_editor: Entity<KeystrokeInput>,
context_editor: Entity<Editor>, context_editor: Entity<SingleLineInput>,
input_editor: Option<Entity<Editor>>, input_editor: Option<Entity<Editor>>,
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
error: Option<InputError>, error: Option<InputError>,
keymap_editor: Entity<KeymapEditor>, keymap_editor: Entity<KeymapEditor>,
workspace: WeakEntity<Workspace>,
} }
impl ModalView for KeybindingEditorModal {} impl ModalView for KeybindingEditorModal {}
@ -1316,25 +1328,28 @@ impl KeybindingEditorModal {
let keybind_editor = cx let keybind_editor = cx
.new(|cx| KeystrokeInput::new(editing_keybind.keystrokes().map(Vec::from), window, cx)); .new(|cx| KeystrokeInput::new(editing_keybind.keystrokes().map(Vec::from), window, cx));
let context_editor = cx.new(|cx| { let context_editor: Entity<SingleLineInput> = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx); let input = SingleLineInput::new(window, cx, "Keybinding Context")
.label("Edit Context")
.label_size(LabelSize::Default);
if let Some(context) = editing_keybind if let Some(context) = editing_keybind
.context .context
.as_ref() .as_ref()
.and_then(KeybindContextString::local) .and_then(KeybindContextString::local)
{ {
editor.set_text(context.clone(), window, cx); input.editor().update(cx, |editor, cx| {
} else { editor.set_text(context.clone(), window, cx);
editor.set_placeholder_text("Keybinding context", cx); });
} }
cx.spawn(async |editor, cx| { let editor_entity = input.editor().clone();
cx.spawn(async move |_input_handle, cx| {
let contexts = cx let contexts = cx
.background_spawn(async { collect_contexts_from_assets() }) .background_spawn(async { collect_contexts_from_assets() })
.await; .await;
editor editor_entity
.update(cx, |editor, _cx| { .update(cx, |editor, _cx| {
editor.set_completion_provider(Some(std::rc::Rc::new( editor.set_completion_provider(Some(std::rc::Rc::new(
KeyContextCompletionProvider { contexts }, KeyContextCompletionProvider { contexts },
@ -1344,17 +1359,19 @@ impl KeybindingEditorModal {
}) })
.detach_and_log_err(cx); .detach_and_log_err(cx);
editor input
}); });
let input_editor = editing_keybind.action_schema.clone().map(|_schema| { let input_editor = editing_keybind.action_schema.clone().map(|_schema| {
cx.new(|cx| { cx.new(|cx| {
let mut editor = Editor::auto_height_unbounded(1, window, cx); let mut editor = Editor::auto_height_unbounded(1, window, cx);
let workspace = workspace.clone();
if let Some(input) = editing_keybind.action_input.clone() { if let Some(input) = editing_keybind.action_input.clone() {
editor.set_text(input.text, window, cx); editor.set_text(input.text, window, cx);
} else { } else {
// TODO: default value from schema? // TODO: default value from schema?
editor.set_placeholder_text("Action input", cx); editor.set_placeholder_text("Action Input", cx);
} }
cx.spawn(async |editor, cx| { cx.spawn(async |editor, cx| {
let json_language = load_json_language(workspace, cx).await; let json_language = load_json_language(workspace, cx).await;
@ -1383,6 +1400,7 @@ impl KeybindingEditorModal {
input_editor, input_editor,
error: None, error: None,
keymap_editor, keymap_editor,
workspace,
} }
} }
@ -1431,7 +1449,7 @@ impl KeybindingEditorModal {
let tab_size = cx.global::<settings::SettingsStore>().json_tab_size(); let tab_size = cx.global::<settings::SettingsStore>().json_tab_size();
let new_context = self let new_context = self
.context_editor .context_editor
.read_with(cx, |editor, cx| editor.text(cx)); .read_with(cx, |input, cx| input.editor().read(cx).text(cx));
let new_context = new_context.is_empty().not().then_some(new_context); let new_context = new_context.is_empty().not().then_some(new_context);
let new_context_err = new_context.as_deref().and_then(|context| { let new_context_err = new_context.as_deref().and_then(|context| {
gpui::KeyBindingContextPredicate::parse(context) gpui::KeyBindingContextPredicate::parse(context)
@ -1503,6 +1521,25 @@ impl KeybindingEditorModal {
let create = self.creating; let create = self.creating;
let status_toast = StatusToast::new(
format!(
"Saved edits to the {} action.",
command_palette::humanize_action_name(&self.editing_keybind.action_name)
),
cx,
move |this, _cx| {
this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
.dismiss_button(true)
// .action("Undo", f) todo: wire the undo functionality
},
);
self.workspace
.update(cx, |workspace, cx| {
workspace.toggle_status_toast(status_toast, cx);
})
.log_err();
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
if let Err(err) = save_keybinding_update( if let Err(err) = save_keybinding_update(
create, create,
@ -1533,90 +1570,96 @@ impl KeybindingEditorModal {
impl Render for KeybindingEditorModal { impl Render for KeybindingEditorModal {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme().colors(); let theme = cx.theme().colors();
let input_base = || { let action_name =
div() command_palette::humanize_action_name(&self.editing_keybind.action_name).to_string();
.w_full()
.py_2()
.px_3()
.min_h_8()
.rounded_md()
.bg(theme.editor_background)
.border_1()
.border_color(theme.border_variant)
};
v_flex() v_flex().w(rems(34.)).elevation_3(cx).child(
.w(rems(34.)) Modal::new("keybinding_editor_modal", None)
.elevation_3(cx) .header(
.child( ModalHeader::new().child(
v_flex() v_flex()
.p_3() .pb_1p5()
.child(Label::new("Edit Keystroke")) .mb_1()
.child( .gap_0p5()
Label::new("Input the desired keystroke for the selected action.") .border_b_1()
.color(Color::Muted) .border_color(theme.border_variant)
.mb_2(), .child(Label::new(action_name))
) .when_some(self.editing_keybind.action_docs, |this, docs| {
.child(self.keybind_editor.clone()), this.child(
) Label::new(docs).size(LabelSize::Small).color(Color::Muted),
.when_some(self.input_editor.clone(), |this, editor| { )
this.child( }),
v_flex() ),
.p_3()
.pt_0()
.child(Label::new("Edit Input"))
.child(
Label::new("Input the desired input to the binding.")
.color(Color::Muted)
.mb_2(),
)
.child(input_base().child(editor)),
) )
}) .section(
.child( Section::new().child(
v_flex() v_flex()
.p_3() .gap_2()
.pt_0() .child(
.child(Label::new("Edit Context")) v_flex()
.child( .child(Label::new("Edit Keystroke"))
Label::new("Input the desired context for the binding.") .gap_1()
.color(Color::Muted) .child(self.keybind_editor.clone()),
.mb_2(), )
) .when_some(self.input_editor.clone(), |this, editor| {
.child(input_base().child(self.context_editor.clone())), this.child(
) v_flex()
.when_some(self.error.as_ref(), |this, error| { .mt_1p5()
this.child( .gap_1()
div().p_2().child( .child(Label::new("Edit Arguments"))
Banner::new() .child(
.map(|banner| match error { div()
InputError::Error(_) => banner.severity(ui::Severity::Error), .w_full()
InputError::Warning(_) => banner.severity(ui::Severity::Warning), .py_1()
.px_1p5()
.rounded_lg()
.bg(theme.editor_background)
.border_1()
.border_color(theme.border_variant)
.child(editor),
),
)
}) })
// For some reason, the div overflows its container to the .child(self.context_editor.clone())
// right. The padding accounts for that. .when_some(self.error.as_ref(), |this, error| {
.child(div().size_full().pr_2().child(Label::new(error.content()))), this.child(
Banner::new()
.map(|banner| match error {
InputError::Error(_) => {
banner.severity(ui::Severity::Error)
}
InputError::Warning(_) => {
banner.severity(ui::Severity::Warning)
}
})
// For some reason, the div overflows its container to the
//right. The padding accounts for that.
.child(
div()
.size_full()
.pr_2()
.child(Label::new(error.content())),
),
)
}),
), ),
) )
}) .footer(
.child( ModalFooter::new().end_slot(
h_flex() h_flex()
.p_2() .gap_1()
.w_full() .child(
.gap_1() Button::new("cancel", "Cancel")
.justify_end() .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
.border_t_1() )
.border_color(theme.border_variant) .child(Button::new("save-btn", "Save").on_click(cx.listener(
.child( |this, _event, _window, cx| {
Button::new("cancel", "Cancel") this.save(cx);
.on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))), },
) ))),
.child(
Button::new("save-btn", "Save").on_click(
cx.listener(|this, _event, _window, cx| Self::save(this, cx)),
),
), ),
) ),
)
} }
} }
@ -1848,6 +1891,7 @@ struct KeystrokeInput {
inner_focus_handle: FocusHandle, inner_focus_handle: FocusHandle,
intercept_subscription: Option<Subscription>, intercept_subscription: Option<Subscription>,
_focus_subscriptions: [Subscription; 2], _focus_subscriptions: [Subscription; 2],
search: bool,
} }
impl KeystrokeInput { impl KeystrokeInput {
@ -1870,6 +1914,7 @@ impl KeystrokeInput {
outer_focus_handle, outer_focus_handle,
intercept_subscription: None, intercept_subscription: None,
_focus_subscriptions, _focus_subscriptions,
search: false,
} }
} }
@ -1987,6 +2032,14 @@ impl KeystrokeInput {
)) ))
}) })
} }
fn recording_focus_handle(&self, _cx: &App) -> FocusHandle {
self.inner_focus_handle.clone()
}
fn set_search_mode(&mut self, search: bool) {
self.search = search;
}
} }
impl EventEmitter<()> for KeystrokeInput {} impl EventEmitter<()> for KeystrokeInput {}
@ -2000,7 +2053,84 @@ impl Focusable for KeystrokeInput {
impl Render for KeystrokeInput { impl Render for KeystrokeInput {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.theme().colors(); let colors = cx.theme().colors();
let is_inner_focused = self.inner_focus_handle.is_focused(window); let is_focused = self.outer_focus_handle.contains_focused(window, cx);
let is_recording = self.inner_focus_handle.is_focused(window);
let horizontal_padding = rems_from_px(64.);
let recording_bg_color = colors
.editor_background
.blend(colors.text_accent.opacity(0.1));
let recording_indicator = h_flex()
.h_4()
.pr_1()
.gap_0p5()
.border_1()
.border_color(colors.border)
.bg(colors
.editor_background
.blend(colors.text_accent.opacity(0.1)))
.rounded_sm()
.child(
Icon::new(IconName::Circle)
.size(IconSize::Small)
.color(Color::Error)
.with_animation(
"recording-pulse",
Animation::new(std::time::Duration::from_secs(2))
.repeat()
.with_easing(gpui::pulsating_between(0.4, 0.8)),
{
let color = Color::Error.color(cx);
move |this, delta| this.color(Color::Custom(color.opacity(delta)))
},
),
)
.child(
Label::new("REC")
.size(LabelSize::XSmall)
.weight(FontWeight::SEMIBOLD)
.color(Color::Error),
);
let search_indicator = h_flex()
.h_4()
.pr_1()
.gap_0p5()
.border_1()
.border_color(colors.border)
.bg(colors
.editor_background
.blend(colors.text_accent.opacity(0.1)))
.rounded_sm()
.child(
Icon::new(IconName::Circle)
.size(IconSize::Small)
.color(Color::Accent)
.with_animation(
"recording-pulse",
Animation::new(std::time::Duration::from_secs(2))
.repeat()
.with_easing(gpui::pulsating_between(0.4, 0.8)),
{
let color = Color::Accent.color(cx);
move |this, delta| this.color(Color::Custom(color.opacity(delta)))
},
),
)
.child(
Label::new("SEARCH")
.size(LabelSize::XSmall)
.weight(FontWeight::SEMIBOLD)
.color(Color::Accent),
);
let record_icon = if self.search {
IconName::MagnifyingGlass
} else {
IconName::PlayFilled
};
return h_flex() return h_flex()
.id("keystroke-input") .id("keystroke-input")
@ -2008,18 +2138,23 @@ impl Render for KeystrokeInput {
.py_2() .py_2()
.px_3() .px_3()
.gap_2() .gap_2()
.min_h_8() .min_h_10()
.w_full() .w_full()
.flex_1() .flex_1()
.justify_between() .justify_between()
.rounded_md() .rounded_lg()
.overflow_hidden() .overflow_hidden()
.bg(colors.editor_background) .map(|this| {
.border_2() if is_recording {
this.bg(recording_bg_color)
} else {
this.bg(colors.editor_background)
}
})
.border_1()
.border_color(colors.border_variant) .border_color(colors.border_variant)
.focus(|mut s| { .when(is_focused, |parent| {
s.border_color = Some(colors.border_focused); parent.border_color(colors.border_focused)
s
}) })
.on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
// TODO: replace with action // TODO: replace with action
@ -2028,19 +2163,29 @@ impl Render for KeystrokeInput {
cx.notify(); cx.notify();
} }
})) }))
.child(
h_flex()
.w(horizontal_padding)
.gap_0p5()
.justify_start()
.flex_none()
.when(is_recording, |this| {
this.map(|this| {
if self.search {
this.child(search_indicator)
} else {
this.child(recording_indicator)
}
})
}),
)
.child( .child(
h_flex() h_flex()
.id("keystroke-input-inner") .id("keystroke-input-inner")
.track_focus(&self.inner_focus_handle) .track_focus(&self.inner_focus_handle)
.on_modifiers_changed(cx.listener(Self::on_modifiers_changed)) .on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
.on_key_up(cx.listener(Self::on_key_up)) .on_key_up(cx.listener(Self::on_key_up))
.when(self.highlight_on_focus, |this| { .size_full()
this.focus(|mut style| {
style.border_color = Some(colors.border_focused);
style
})
})
.w_full()
.min_w_0() .min_w_0()
.justify_center() .justify_center()
.flex_wrap() .flex_wrap()
@ -2049,40 +2194,52 @@ impl Render for KeystrokeInput {
) )
.child( .child(
h_flex() h_flex()
.w(horizontal_padding)
.gap_0p5() .gap_0p5()
.justify_end()
.flex_none() .flex_none()
.when(is_inner_focused, |this| { .map(|this| {
this.child( if is_recording {
Icon::new(IconName::Circle) this.child(
.color(Color::Error) IconButton::new("stop-record-btn", IconName::StopFilled)
.with_animation( .shape(ui::IconButtonShape::Square)
"recording-pulse", .map(|this| {
gpui::Animation::new(std::time::Duration::from_secs(1)) if self.search {
.repeat() this.tooltip(Tooltip::text("Stop Searching"))
.with_easing(gpui::pulsating_between(0.8, 1.0)), } else {
{ this.tooltip(Tooltip::text("Stop Recording"))
let color = Color::Error.color(cx);
move |this, delta| {
this.color(Color::Custom(color.opacity(delta)))
} }
}, })
), .icon_color(Color::Error)
) .on_click(cx.listener(|this, _event, window, _cx| {
this.outer_focus_handle.focus(window);
})),
)
} else {
this.child(
IconButton::new("record-btn", record_icon)
.shape(ui::IconButtonShape::Square)
.map(|this| {
if self.search {
this.tooltip(Tooltip::text("Start Searching"))
} else {
this.tooltip(Tooltip::text("Start Recording"))
}
})
.when(!is_focused, |this| this.icon_color(Color::Muted))
.on_click(cx.listener(|this, _event, window, _cx| {
this.inner_focus_handle.focus(window);
})),
)
}
}) })
.child( .child(
IconButton::new("backspace-btn", IconName::Delete) IconButton::new("clear-btn", IconName::Delete)
.tooltip(Tooltip::text("Delete Keystroke")) .shape(ui::IconButtonShape::Square)
.when(!is_inner_focused, |this| this.icon_color(Color::Muted))
.on_click(cx.listener(|this, _event, _window, cx| {
this.keystrokes.pop();
cx.emit(());
cx.notify();
})),
)
.child(
IconButton::new("clear-btn", IconName::Eraser)
.tooltip(Tooltip::text("Clear Keystrokes")) .tooltip(Tooltip::text("Clear Keystrokes"))
.when(!is_inner_focused, |this| this.icon_color(Color::Muted)) .when(!is_recording || !is_focused, |this| {
this.icon_color(Color::Muted)
})
.on_click(cx.listener(|this, _event, _window, cx| { .on_click(cx.listener(|this, _event, _window, cx| {
this.keystrokes.clear(); this.keystrokes.clear();
cx.emit(()); cx.emit(());

View file

@ -27,6 +27,8 @@ pub struct SingleLineInput {
/// ///
/// Its position is determined by the [`FieldLabelLayout`]. /// Its position is determined by the [`FieldLabelLayout`].
label: Option<SharedString>, label: Option<SharedString>,
/// The size of the label text.
label_size: LabelSize,
/// The placeholder text for the text field. /// The placeholder text for the text field.
placeholder: SharedString, placeholder: SharedString,
/// Exposes the underlying [`Entity<Editor>`] to allow for customizing the editor beyond the provided API. /// Exposes the underlying [`Entity<Editor>`] to allow for customizing the editor beyond the provided API.
@ -59,6 +61,7 @@ impl SingleLineInput {
Self { Self {
label: None, label: None,
label_size: LabelSize::Small,
placeholder: placeholder_text, placeholder: placeholder_text,
editor, editor,
start_icon: None, start_icon: None,
@ -76,6 +79,11 @@ impl SingleLineInput {
self self
} }
pub fn label_size(mut self, size: LabelSize) -> Self {
self.label_size = size;
self
}
pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) { pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
self.disabled = disabled; self.disabled = disabled;
self.editor self.editor
@ -138,7 +146,7 @@ impl Render for SingleLineInput {
.when_some(self.label.clone(), |this, label| { .when_some(self.label.clone(), |this, label| {
this.child( this.child(
Label::new(label) Label::new(label)
.size(LabelSize::Small) .size(self.label_size)
.color(if self.disabled { .color(if self.disabled {
Color::Disabled Color::Disabled
} else { } else {
@ -148,16 +156,17 @@ impl Render for SingleLineInput {
}) })
.child( .child(
h_flex() h_flex()
.min_w_48()
.min_h_8()
.w_full()
.px_2() .px_2()
.py_1p5() .py_1p5()
.bg(style.background_color) .flex_grow()
.text_color(style.text_color) .text_color(style.text_color)
.rounded_md() .rounded_lg()
.bg(style.background_color)
.border_1() .border_1()
.border_color(style.border_color) .border_color(style.border_color)
.min_w_48()
.w_full()
.flex_grow()
.when_some(self.start_icon, |this, icon| { .when_some(self.start_icon, |this, icon| {
this.gap_1() this.gap_1()
.child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
@ -173,16 +182,28 @@ impl Component for SingleLineInput {
} }
fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> { fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let input_1 = let input_small =
cx.new(|cx| SingleLineInput::new(window, cx, "placeholder").label("Some Label")); cx.new(|cx| SingleLineInput::new(window, cx, "placeholder").label("Small Label"));
let input_regular = cx.new(|cx| {
SingleLineInput::new(window, cx, "placeholder")
.label("Regular Label")
.label_size(LabelSize::Default)
});
Some( Some(
v_flex() v_flex()
.gap_6() .gap_6()
.children(vec![example_group(vec![single_example( .children(vec![example_group(vec![
"Default", single_example(
div().child(input_1.clone()).into_any_element(), "Small Label (Default)",
)])]) div().child(input_small.clone()).into_any_element(),
),
single_example(
"Regular Label",
div().child(input_regular.clone()).into_any_element(),
),
])])
.into_any_element(), .into_any_element(),
) )
} }