1164 lines
39 KiB
Rust
1164 lines
39 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use super::{
|
|
settings_page::{
|
|
render_sub_header, LocalOnlyIconState, MatchData, PageType, SettingsPageMeta,
|
|
SettingsPageViewHandle, SettingsWidget,
|
|
},
|
|
SettingsSection,
|
|
};
|
|
use crate::send_telemetry_from_ctx;
|
|
use crate::{appearance::Appearance, themes};
|
|
use crate::{
|
|
editor::EditorView, keyboard::write_custom_keybinding, util::bindings::CommandBinding,
|
|
};
|
|
use crate::{
|
|
editor::{
|
|
Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextOptions,
|
|
},
|
|
keyboard::UserDefinedKeybinding,
|
|
};
|
|
use crate::{search_bar::SearchBar, settings::CloudPreferencesSettings};
|
|
use crate::{
|
|
util::bindings::{
|
|
filter_bindings_including_keystroke, reset_keybinding_to_default, set_custom_keybinding,
|
|
},
|
|
TelemetryEvent,
|
|
};
|
|
use itertools::Itertools;
|
|
|
|
use warp_core::ui::theme::color::internal_colors;
|
|
use warpui::{elements::Wrap, units::Pixels};
|
|
use warpui::{
|
|
elements::{
|
|
Align, Border, ClippedScrollStateHandle, ClippedScrollable, Container, CornerRadius, Empty,
|
|
EventHandler, Fill, Flex, Hoverable, MouseState, MouseStateHandle, ParentElement, Radius,
|
|
SavePosition, ScrollbarWidth, Shrinkable,
|
|
},
|
|
fonts::Weight,
|
|
keymap::{Keystroke, Trigger},
|
|
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
|
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
|
};
|
|
use warpui::{
|
|
elements::{ConstrainedBox, DispatchEventResult},
|
|
presenter::ChildView,
|
|
};
|
|
use warpui::{
|
|
elements::{CrossAxisAlignment, Text},
|
|
keymap::DescriptionContext,
|
|
};
|
|
|
|
const FONT_DELTA: f32 = 2.;
|
|
const CANCEL_SAVE_BUTTONS_SPACING: f32 = 4.0;
|
|
const CLEAR_CANCEL_BUTTONS_SPACING: f32 = 8.0;
|
|
const ROW_INTERNAL_VERTICAL_PADDING: f32 = 8.0;
|
|
const ROW_LEFT_MARGIN: f32 = 20.0;
|
|
const ROW_HEIGHT: f32 = 28.;
|
|
const EDIT_BUTTONS_BORDER_RADIUS: f32 = 4.0;
|
|
|
|
pub const SEARCH_PLACEHOLDER: &str = "Search by name or by keys (ex. \"cmd d\")";
|
|
const SHORTCUT_CONFLICT_WARNING_TEXT: &str = "This shortcut conflicts with other keybinds";
|
|
const KEYBINDINGS_PAGE_SHORTCUT: &str = "workspace:toggle_keybindings_page";
|
|
const RESET_BUTTON_TEXT: &str = "Default";
|
|
const CANCEL_BUTTON_TEXT: &str = "Cancel";
|
|
const CLEAR_BUTTON_TEXT: &str = "Clear";
|
|
const SAVE_BUTTON_TEXT: &str = "Save";
|
|
|
|
/// Notifier for custom keybinding changed. Views could subscribe to this for
|
|
/// KeybindingChangedEvent.
|
|
#[derive(Default)]
|
|
pub struct KeybindingChangedNotifier {}
|
|
|
|
impl KeybindingChangedNotifier {
|
|
pub fn new() -> Self {
|
|
Default::default()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn mock() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
pub enum KeybindingChangedEvent {
|
|
BindingChanged {
|
|
/// Name of the keybinding that is being changed.
|
|
binding_name: String,
|
|
new_trigger: Option<Keystroke>,
|
|
},
|
|
}
|
|
|
|
impl Entity for KeybindingChangedNotifier {
|
|
type Event = KeybindingChangedEvent;
|
|
}
|
|
|
|
impl SingletonEntity for KeybindingChangedNotifier {}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct KeyBindingModifyingState {
|
|
pub current_binding: Option<Keystroke>,
|
|
pub unsaved_binding: Option<Keystroke>,
|
|
}
|
|
|
|
impl KeyBindingModifyingState {
|
|
pub fn new(state: Option<Keystroke>) -> KeyBindingModifyingState {
|
|
Self {
|
|
current_binding: state.clone(),
|
|
unsaved_binding: state,
|
|
}
|
|
}
|
|
|
|
pub fn is_dirty(&self) -> bool {
|
|
self.current_binding != self.unsaved_binding
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
struct ConflictMap {
|
|
map: HashMap<Keystroke, usize>,
|
|
}
|
|
|
|
impl ConflictMap {
|
|
fn update(&mut self, old: &Option<Keystroke>, new: Option<Keystroke>) {
|
|
if let Some(old) = old {
|
|
if let Some(old_conflict_count) = self.map.get_mut(old) {
|
|
*old_conflict_count = old_conflict_count.saturating_sub(1);
|
|
}
|
|
}
|
|
|
|
if let Some(new) = new {
|
|
let new_conflict_count = self.map.entry(new).or_default();
|
|
*new_conflict_count += 1;
|
|
}
|
|
}
|
|
|
|
fn has_conflict(&self, key: &Option<Keystroke>) -> bool {
|
|
match key {
|
|
Some(key) => self
|
|
.map
|
|
.get(key)
|
|
.map(|count| *count > 1)
|
|
.unwrap_or_default(),
|
|
None => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FromIterator<Option<Keystroke>> for ConflictMap {
|
|
fn from_iter<I: IntoIterator<Item = Option<Keystroke>>>(iter: I) -> Self {
|
|
let mut map = HashMap::new();
|
|
|
|
for binding in iter.into_iter().flatten() {
|
|
let counter = map.entry(binding).or_default();
|
|
*counter += 1;
|
|
}
|
|
|
|
ConflictMap { map }
|
|
}
|
|
}
|
|
|
|
pub struct KeybindingsView {
|
|
page: PageType<Self>,
|
|
search_editor: ViewHandle<EditorView>,
|
|
search_bar: ViewHandle<SearchBar>,
|
|
clipped_scroll_state: ClippedScrollStateHandle,
|
|
bindings: Option<Vec<CommandBinding>>,
|
|
modifying_row: Option<KeyBindingModifyingState>,
|
|
pub rows: Option<Vec<KeybindingRow>>,
|
|
// Map between the keystroke and the number of conflicting bindings associated with the keystroke.
|
|
// The bindings could be unsaved.
|
|
conflict_map: ConflictMap,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum KeybindingsViewAction {
|
|
KeybindingRowClicked(usize),
|
|
KeystrokeDefined(usize, Keystroke),
|
|
ResetToDefaultKeyStroke(usize),
|
|
CancelKeyStrokeEditing(usize),
|
|
ConfirmKeyStroke(usize),
|
|
RemoveKeyStroke(usize),
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
struct RowMouseStates {
|
|
keystroke_row_mouse_state: MouseStateHandle,
|
|
reset_to_default_mouse_state: MouseStateHandle,
|
|
remove_mouse_state: MouseStateHandle,
|
|
cancel_mouse_state: MouseStateHandle,
|
|
save_mouse_state: MouseStateHandle,
|
|
}
|
|
|
|
/// Wrapper around the CommandBinding structure that includes the styling/render-specific
|
|
/// attribtues (such as MouseStateHandles)
|
|
#[derive(Clone)]
|
|
pub struct KeybindingRow {
|
|
pub binding: CommandBinding,
|
|
mouse_state_handles: RowMouseStates,
|
|
editor_open: bool,
|
|
}
|
|
|
|
impl From<(Option<Vec<usize>>, &CommandBinding)> for KeybindingRow {
|
|
fn from(orig: (Option<Vec<usize>>, &CommandBinding)) -> Self {
|
|
Self {
|
|
binding: orig.1.clone(),
|
|
mouse_state_handles: Default::default(),
|
|
editor_open: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl KeybindingRow {
|
|
fn render(
|
|
&self,
|
|
index: usize,
|
|
is_disabled: bool,
|
|
has_conflicting_binding: bool,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
let inner = if !is_disabled {
|
|
let mut row = Hoverable::new(
|
|
self.mouse_state_handles.keystroke_row_mouse_state.clone(),
|
|
|state| {
|
|
let background = if state.is_hovered() {
|
|
Some(appearance.theme().accent().with_opacity(40).into())
|
|
} else if index.is_multiple_of(2) {
|
|
Some(internal_colors::fg_overlay_1(appearance.theme()).into())
|
|
} else {
|
|
None
|
|
};
|
|
if self.editor_open {
|
|
self.render_clicked(index, has_conflicting_binding, appearance)
|
|
} else {
|
|
self.render_summary(None, background, has_conflicting_binding, appearance)
|
|
}
|
|
},
|
|
);
|
|
|
|
if !self.editor_open {
|
|
row = row.on_click(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(KeybindingsViewAction::KeybindingRowClicked(index));
|
|
});
|
|
}
|
|
|
|
row.finish()
|
|
} else {
|
|
let background = if index.is_multiple_of(2) {
|
|
Some(internal_colors::fg_overlay_1(appearance.theme()).into())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Container::new(self.render_summary(
|
|
None,
|
|
background,
|
|
has_conflicting_binding,
|
|
appearance,
|
|
))
|
|
.with_foreground_overlay(appearance.theme().keybinding_row_overlay())
|
|
.finish()
|
|
};
|
|
|
|
if index == 0 {
|
|
SavePosition::new(inner, "first_keybinding_setting").finish()
|
|
} else {
|
|
inner
|
|
}
|
|
}
|
|
|
|
fn render_summary(
|
|
&self,
|
|
index: Option<usize>,
|
|
background: Option<Fill>,
|
|
has_conflicting_binding: bool,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
let binding = &self.binding;
|
|
let keystroke = match binding.trigger.clone() {
|
|
None => Empty::new().finish(),
|
|
Some(keystroke) => {
|
|
let mut keyshortcut = appearance.ui_builder().keyboard_shortcut(&keystroke);
|
|
|
|
if has_conflicting_binding {
|
|
keyshortcut = keyshortcut.with_style(UiComponentStyles {
|
|
border_width: Some(2.),
|
|
border_color: Some(themes::theme::Fill::warn().into()),
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
keyshortcut.build().finish()
|
|
}
|
|
};
|
|
let element = render_columns(
|
|
render_text(
|
|
binding.description.in_context(DescriptionContext::Default),
|
|
None,
|
|
appearance,
|
|
),
|
|
keystroke,
|
|
0.7,
|
|
background,
|
|
None,
|
|
);
|
|
if let Some(index) = index {
|
|
EventHandler::new(element)
|
|
.on_keydown(move |ctx, _, keystroke| {
|
|
ctx.dispatch_typed_action(KeybindingsViewAction::KeystrokeDefined(
|
|
index,
|
|
keystroke.clone(),
|
|
));
|
|
DispatchEventResult::StopPropagation
|
|
})
|
|
.finish()
|
|
} else {
|
|
element
|
|
}
|
|
}
|
|
|
|
fn render_clicked(
|
|
&self,
|
|
index: usize,
|
|
has_conflicting_binding: bool,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
let conflict_warning = if has_conflicting_binding {
|
|
render_text(
|
|
SHORTCUT_CONFLICT_WARNING_TEXT,
|
|
Some(UiComponentStyles {
|
|
font_weight: Some(Weight::Bold),
|
|
..Default::default()
|
|
}),
|
|
appearance,
|
|
)
|
|
} else {
|
|
Empty::new().finish()
|
|
};
|
|
|
|
let press_new_shortcut_text = render_text("Press new keyboard shortcut", None, appearance);
|
|
|
|
let new_shortcut_element = Container::new(press_new_shortcut_text)
|
|
.with_margin_left(ROW_LEFT_MARGIN)
|
|
.with_margin_top(8.0)
|
|
.finish();
|
|
|
|
Container::new(
|
|
Flex::column()
|
|
.with_child(self.render_summary(
|
|
Some(index),
|
|
Some(appearance.theme().accent().into()),
|
|
has_conflicting_binding,
|
|
appearance,
|
|
))
|
|
.with_child(
|
|
Container::new(new_shortcut_element)
|
|
.with_margin_bottom(ROW_INTERNAL_VERTICAL_PADDING)
|
|
.finish(),
|
|
)
|
|
.with_child(
|
|
Flex::row()
|
|
.with_child(
|
|
Shrinkable::new(
|
|
1.,
|
|
Align::new(
|
|
Container::new(conflict_warning)
|
|
.with_margin_left(ROW_LEFT_MARGIN)
|
|
.finish(),
|
|
)
|
|
.left()
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_child(
|
|
Container::new(self.get_edit_button_row(appearance, index))
|
|
.with_margin_right(CLEAR_CANCEL_BUTTONS_SPACING)
|
|
.finish(),
|
|
)
|
|
.with_cross_axis_alignment(warpui::elements::CrossAxisAlignment::Center)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_padding_bottom(ROW_INTERNAL_VERTICAL_PADDING)
|
|
.with_background(appearance.theme().accent().with_opacity(40))
|
|
.finish()
|
|
}
|
|
|
|
fn get_button_text_color(
|
|
&self,
|
|
appearance: &Appearance,
|
|
state: &MouseState,
|
|
) -> themes::theme::Fill {
|
|
let main_text_color: themes::theme::Fill = appearance
|
|
.theme()
|
|
.main_text_color(appearance.theme().surface_2());
|
|
|
|
if state.is_hovered() {
|
|
main_text_color
|
|
} else if state.is_clicked() {
|
|
main_text_color.with_opacity(50)
|
|
} else {
|
|
main_text_color.with_opacity(90)
|
|
}
|
|
}
|
|
|
|
fn get_edit_button_row(&self, appearance: &Appearance, index: usize) -> Box<dyn Element> {
|
|
let mut edit_buttons_based_on_state = Vec::new();
|
|
|
|
if self.binding.trigger.is_some() {
|
|
let clear = Hoverable::new(
|
|
self.mouse_state_handles.remove_mouse_state.clone(),
|
|
|state| {
|
|
render_button(
|
|
CLEAR_BUTTON_TEXT,
|
|
appearance,
|
|
self.get_button_text_color(appearance, state),
|
|
)
|
|
},
|
|
)
|
|
.on_click(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(KeybindingsViewAction::RemoveKeyStroke(index));
|
|
})
|
|
.finish();
|
|
|
|
edit_buttons_based_on_state.push(clear);
|
|
}
|
|
|
|
let clear = Container::new(
|
|
Hoverable::new(
|
|
self.mouse_state_handles
|
|
.reset_to_default_mouse_state
|
|
.clone(),
|
|
|state| {
|
|
render_button(
|
|
RESET_BUTTON_TEXT,
|
|
appearance,
|
|
self.get_button_text_color(appearance, state),
|
|
)
|
|
},
|
|
)
|
|
.on_click(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(KeybindingsViewAction::ResetToDefaultKeyStroke(index));
|
|
})
|
|
.finish(),
|
|
)
|
|
.with_padding_left(CLEAR_CANCEL_BUTTONS_SPACING)
|
|
.finish();
|
|
edit_buttons_based_on_state.push(clear);
|
|
|
|
let cancel = Container::new(
|
|
Hoverable::new(
|
|
self.mouse_state_handles.cancel_mouse_state.clone(),
|
|
|state| {
|
|
let cancel_button_color = self.get_button_text_color(appearance, state);
|
|
if index == 0 {
|
|
SavePosition::new(
|
|
render_button(CANCEL_BUTTON_TEXT, appearance, cancel_button_color),
|
|
"first_keybinding_cancel",
|
|
)
|
|
.finish()
|
|
} else {
|
|
render_button("Cancel", appearance, cancel_button_color)
|
|
}
|
|
},
|
|
)
|
|
.on_click(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(KeybindingsViewAction::CancelKeyStrokeEditing(index));
|
|
})
|
|
.finish(),
|
|
)
|
|
.with_padding_left(CLEAR_CANCEL_BUTTONS_SPACING)
|
|
.finish();
|
|
|
|
edit_buttons_based_on_state.push(cancel);
|
|
|
|
let save = Container::new(
|
|
Hoverable::new(self.mouse_state_handles.save_mouse_state.clone(), |state| {
|
|
render_button(
|
|
SAVE_BUTTON_TEXT,
|
|
appearance,
|
|
self.get_button_text_color(appearance, state),
|
|
)
|
|
})
|
|
.on_click(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(KeybindingsViewAction::ConfirmKeyStroke(index));
|
|
})
|
|
.finish(),
|
|
)
|
|
.with_padding_left(CANCEL_SAVE_BUTTONS_SPACING)
|
|
.finish();
|
|
edit_buttons_based_on_state.push(save);
|
|
|
|
Flex::row()
|
|
.with_children(edit_buttons_based_on_state)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl KeybindingsView {
|
|
pub fn new(ctx: &mut ViewContext<KeybindingsView>) -> Self {
|
|
let search_editor = {
|
|
let appearance = Appearance::as_ref(ctx);
|
|
let options = SingleLineEditorOptions {
|
|
text: TextOptions {
|
|
font_size_override: Some(appearance.ui_font_size()),
|
|
..Default::default()
|
|
},
|
|
propagate_and_no_op_vertical_navigation_keys:
|
|
PropagateAndNoOpNavigationKeys::Always,
|
|
..Default::default()
|
|
};
|
|
ctx.add_typed_action_view(|ctx| EditorView::single_line(options, ctx))
|
|
};
|
|
ctx.subscribe_to_view(&search_editor, move |me, _, event, ctx| {
|
|
me.handle_search_editor_event(event, ctx);
|
|
});
|
|
|
|
search_editor.update(ctx, |editor, ctx| {
|
|
editor.clear_buffer_and_reset_undo_stack(ctx);
|
|
editor.set_placeholder_text(SEARCH_PLACEHOLDER, ctx);
|
|
});
|
|
|
|
let search_bar = ctx.add_typed_action_view(|_| SearchBar::new(search_editor.clone()));
|
|
|
|
let page = PageType::new_monolith(KeybindingsWidget::default(), None, false);
|
|
Self {
|
|
page,
|
|
clipped_scroll_state: Default::default(),
|
|
bindings: None,
|
|
rows: Default::default(),
|
|
modifying_row: None,
|
|
search_bar,
|
|
search_editor,
|
|
conflict_map: Default::default(),
|
|
}
|
|
}
|
|
|
|
/// Searches for a keybinding as if the user had typed the query into the search
|
|
/// box. Will filter the keybinding list by the query.
|
|
pub fn search_for_binding(&mut self, query: &str, ctx: &mut ViewContext<Self>) {
|
|
self.search_editor.update(ctx, |editor, ctx| {
|
|
editor.set_buffer_text(query, ctx);
|
|
});
|
|
self.filter_bindings(query, ctx);
|
|
}
|
|
|
|
/// Filter the list of visible bindings by the given query.
|
|
fn filter_bindings(&mut self, query: &str, ctx: &mut ViewContext<Self>) {
|
|
self.rows = Some(
|
|
filter_bindings_including_keystroke(
|
|
self.bindings.iter().flatten(),
|
|
query,
|
|
DescriptionContext::Default,
|
|
)
|
|
.map(KeybindingRow::from)
|
|
.collect(),
|
|
);
|
|
|
|
self.clipped_scroll_state.scroll_to(Pixels::zero());
|
|
ctx.notify();
|
|
}
|
|
|
|
fn handle_search_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
|
|
match event {
|
|
EditorEvent::Edited(_) => {
|
|
let search_term = self.search_editor.as_ref(ctx).buffer_text(ctx);
|
|
self.filter_bindings(&search_term, ctx);
|
|
}
|
|
EditorEvent::Enter => ctx.notify(),
|
|
EditorEvent::Escape => ctx.focus_self(),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn binding_row_clicked(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
|
|
// Unfocus the search bar.
|
|
ctx.focus_self();
|
|
|
|
// Only enable editing when none of the other keystrokes are being edited.
|
|
if self.modifying_row.is_none() {
|
|
let maybe_row = self
|
|
.rows
|
|
.as_mut()
|
|
.into_iter()
|
|
.flatten()
|
|
.enumerate()
|
|
.find(|(idx, _)| *idx == index);
|
|
|
|
if let Some((_, row)) = maybe_row {
|
|
ctx.disable_key_bindings_dispatching();
|
|
self.modifying_row =
|
|
Some(KeyBindingModifyingState::new(row.binding.trigger.clone()));
|
|
row.editor_open = true;
|
|
|
|
// This is entering the edit mode, and we'll want to capture the keydown events.
|
|
// For that all actions are being suppressed for the given window.
|
|
ctx.notify();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn remove_keystroke(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
|
|
if let Some(row) = self.rows.as_mut().and_then(|rows| rows.get_mut(index)) {
|
|
ctx.set_custom_trigger(row.binding.name.clone(), Trigger::Empty);
|
|
|
|
trigger_keybinding_notifier(row.binding.name.clone(), None, ctx);
|
|
|
|
self.conflict_map.update(&row.binding.trigger, None);
|
|
|
|
// Persist the keybinding into the `.warp` directory so that it will last beyond
|
|
// this session
|
|
write_custom_keybinding(row.binding.name.clone(), UserDefinedKeybinding::Removed);
|
|
update_binding_list(&row.binding.name, None, &mut self.bindings);
|
|
row.binding.trigger = None;
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::KeybindingRemoved {
|
|
action: row.binding.name.clone(),
|
|
},
|
|
ctx
|
|
);
|
|
self.modifying_row = None;
|
|
row.editor_open = false;
|
|
ctx.enable_key_bindings_dispatching();
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
fn reset_to_default_keystroke(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
|
|
if let Some(row) = self.rows.as_mut().and_then(|rows| rows.get_mut(index)) {
|
|
let default_trigger = reset_keybinding_to_default(&row.binding.name, ctx);
|
|
self.conflict_map
|
|
.update(&row.binding.trigger, default_trigger.clone());
|
|
update_binding_list(
|
|
&row.binding.name,
|
|
default_trigger.clone(),
|
|
&mut self.bindings,
|
|
);
|
|
row.binding.trigger = default_trigger;
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::KeybindingResetToDefault {
|
|
action: row.binding.name.clone(),
|
|
},
|
|
ctx
|
|
);
|
|
|
|
self.modifying_row = None;
|
|
row.editor_open = false;
|
|
ctx.enable_key_bindings_dispatching();
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
fn cancel_keystroke_editing(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
|
|
if let Some(row) = self.rows.as_mut().and_then(|rows| rows.get_mut(index)) {
|
|
match self.modifying_row.take() {
|
|
Some(keybinding_state) => {
|
|
self.conflict_map.update(
|
|
&row.binding.trigger,
|
|
keybinding_state.current_binding.clone(),
|
|
);
|
|
update_binding_list(
|
|
&row.binding.name,
|
|
keybinding_state.current_binding.clone(),
|
|
&mut self.bindings,
|
|
);
|
|
row.binding.trigger = keybinding_state.current_binding;
|
|
|
|
row.editor_open = false;
|
|
ctx.enable_key_bindings_dispatching();
|
|
ctx.notify();
|
|
}
|
|
None => {
|
|
log::error!("Modifying row should exist");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn confirm_keystroke_editing(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
|
|
if let Some(row) = self.rows.as_mut().and_then(|rows| rows.get_mut(index)) {
|
|
match self.modifying_row.take() {
|
|
Some(keybinding_state) => {
|
|
if let Some(key) = keybinding_state.unsaved_binding {
|
|
set_custom_keybinding(&row.binding.name, &key, ctx);
|
|
update_binding_list(
|
|
&row.binding.name,
|
|
Some(key.clone()),
|
|
&mut self.bindings,
|
|
);
|
|
row.binding.trigger = Some(key.clone());
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::KeybindingChanged {
|
|
action: row.binding.name.clone(),
|
|
keystroke: key,
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
row.editor_open = false;
|
|
ctx.enable_key_bindings_dispatching();
|
|
ctx.notify();
|
|
}
|
|
None => {
|
|
log::error!("Modifying row should exist");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn set_temporary_keystroke_state(
|
|
&mut self,
|
|
index: usize,
|
|
key: Keystroke,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
if let Some(row) = self.rows.as_mut().and_then(|rows| rows.get_mut(index)) {
|
|
match &mut self.modifying_row {
|
|
Some(keybinding_state) => {
|
|
keybinding_state.unsaved_binding = Some(key.clone());
|
|
}
|
|
None => {
|
|
log::error!("Modifying row does not exist when it should");
|
|
}
|
|
}
|
|
|
|
self.conflict_map
|
|
.update(&row.binding.trigger, Some(key.clone()));
|
|
row.binding.trigger = Some(key);
|
|
ctx.notify();
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Entity for KeybindingsView {
|
|
type Event = ();
|
|
}
|
|
|
|
impl View for KeybindingsView {
|
|
fn ui_name() -> &'static str {
|
|
"KeybindingsView"
|
|
}
|
|
|
|
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
|
self.page.render(self, app)
|
|
}
|
|
}
|
|
|
|
impl SettingsPageMeta for KeybindingsView {
|
|
fn section() -> SettingsSection {
|
|
SettingsSection::Keybindings
|
|
}
|
|
|
|
fn should_render(&self, _ctx: &AppContext) -> bool {
|
|
true
|
|
}
|
|
|
|
fn on_page_selected(&mut self, allow_steal_focus: bool, ctx: &mut ViewContext<Self>) {
|
|
// Reset previous modifying_row state.
|
|
self.modifying_row = None;
|
|
// `from_editable_lens` materializes any dynamic description resolver
|
|
// before caching, so the dedup below (which compares descriptions)
|
|
// sees concrete strings.
|
|
let lenses: Vec<_> = ctx.editable_bindings().collect();
|
|
self.bindings = Some(
|
|
lenses
|
|
.into_iter()
|
|
.map(|lens| CommandBinding::from_editable_lens(lens, ctx))
|
|
.sorted_by(|a, b| {
|
|
// Sort by description then name so that we can deduplicate bindings by name.
|
|
a.description
|
|
.in_context(DescriptionContext::Default)
|
|
.cmp(b.description.in_context(DescriptionContext::Default))
|
|
.then(a.name.cmp(&b.name))
|
|
})
|
|
// Effectively, editable bindings can only be used by one view, because the
|
|
// corresponding context predicate and typed action are view-specific.
|
|
//
|
|
// If multiple views need equivalent bindings, we handle this by declaring
|
|
// duplicates with the same name and description, but different actions and
|
|
// predicates. Because bindings are saved/loaded by name, changes to one binding
|
|
// will affect the others. To reduce clutter, only show one binding for a given name
|
|
// and description.
|
|
//
|
|
// There are some bindings with the same name, but different descriptions. Because
|
|
// we sort by description first, those bindings won't be deduplicated. This is
|
|
// alright for now, since those bindings have slightly different semantics despite
|
|
// being linked (e.g. find in block vs. find in terminal).
|
|
//
|
|
// TODO: Long-term, we should instead refactor TypedActionView so that common
|
|
// bindings can be declared once and handled by multiple views.
|
|
.dedup_by(|a, b| a.name == b.name && a.description == b.description)
|
|
.collect(),
|
|
);
|
|
self.rows = Some(
|
|
self.bindings
|
|
.iter()
|
|
.flatten()
|
|
.map(|b| (None, b))
|
|
.map(KeybindingRow::from)
|
|
.collect(),
|
|
);
|
|
|
|
// Populate the conflict map at startup.
|
|
self.conflict_map = self
|
|
.bindings
|
|
.iter()
|
|
.flatten()
|
|
.map(|binding| binding.trigger.clone())
|
|
.collect();
|
|
|
|
self.search_editor.update(ctx, |editor, ctx| {
|
|
editor.clear_buffer_and_reset_undo_stack(ctx);
|
|
editor.set_placeholder_text(SEARCH_PLACEHOLDER, ctx);
|
|
});
|
|
|
|
if allow_steal_focus {
|
|
ctx.focus(&self.search_editor);
|
|
}
|
|
ctx.notify();
|
|
}
|
|
|
|
fn on_tab_pressed(&mut self, ctx: &mut ViewContext<Self>) {
|
|
ctx.focus(&self.search_editor);
|
|
}
|
|
|
|
fn update_filter(&mut self, query: &str, ctx: &mut ViewContext<Self>) -> MatchData {
|
|
self.page.update_filter(query, ctx)
|
|
}
|
|
|
|
fn scroll_to_widget(&mut self, widget_id: &'static str) {
|
|
self.page.scroll_to_widget(widget_id)
|
|
}
|
|
|
|
fn clear_highlighted_widget(&mut self) {
|
|
self.page.clear_highlighted_widget();
|
|
}
|
|
}
|
|
|
|
impl From<ViewHandle<KeybindingsView>> for SettingsPageViewHandle {
|
|
fn from(view_handle: ViewHandle<KeybindingsView>) -> Self {
|
|
SettingsPageViewHandle::Keybindings(view_handle)
|
|
}
|
|
}
|
|
|
|
impl TypedActionView for KeybindingsView {
|
|
type Action = KeybindingsViewAction;
|
|
|
|
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
|
use KeybindingsViewAction::*;
|
|
|
|
match action {
|
|
RemoveKeyStroke(index) => self.remove_keystroke(*index, ctx),
|
|
ResetToDefaultKeyStroke(index) => self.reset_to_default_keystroke(*index, ctx),
|
|
CancelKeyStrokeEditing(index) => self.cancel_keystroke_editing(*index, ctx),
|
|
ConfirmKeyStroke(index) => self.confirm_keystroke_editing(*index, ctx),
|
|
KeybindingRowClicked(index) => self.binding_row_clicked(*index, ctx),
|
|
KeystrokeDefined(index, key) => {
|
|
self.set_temporary_keystroke_state(*index, key.clone(), ctx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO maybe this should be turned into a table ui component?
|
|
fn render_columns(
|
|
left: Box<dyn Element>,
|
|
right: Box<dyn Element>,
|
|
left_column_flex: f32,
|
|
background: Option<Fill>,
|
|
padding: Option<Coords>,
|
|
) -> Box<dyn Element> {
|
|
let columns = Flex::row()
|
|
.with_child(Shrinkable::new(left_column_flex, Align::new(left).left().finish()).finish())
|
|
.with_child(
|
|
Shrinkable::new(1. - left_column_flex, Align::new(right).left().finish()).finish(),
|
|
)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.finish();
|
|
|
|
let mut container = Container::new(
|
|
ConstrainedBox::new(columns)
|
|
.with_min_height(ROW_HEIGHT)
|
|
.finish(),
|
|
);
|
|
if let Some(padding) = padding {
|
|
container = container
|
|
.with_padding_top(padding.top)
|
|
.with_padding_bottom(padding.bottom)
|
|
.with_padding_right(padding.right)
|
|
.with_padding_left(padding.left);
|
|
} else {
|
|
container = container
|
|
.with_padding_top(10.)
|
|
.with_padding_bottom(10.)
|
|
.with_padding_right(20.)
|
|
.with_padding_left(20.);
|
|
};
|
|
if let Some(background) = background {
|
|
container.with_background(background).finish()
|
|
} else {
|
|
container.finish()
|
|
}
|
|
}
|
|
|
|
fn render_button(
|
|
text: &'static str,
|
|
appearance: &Appearance,
|
|
line_color: themes::theme::Fill,
|
|
) -> Box<dyn Element> {
|
|
Container::new(
|
|
Text::new_inline(text, appearance.ui_font_family(), appearance.ui_font_size())
|
|
.with_color(line_color.into())
|
|
.finish(),
|
|
)
|
|
.with_uniform_padding(4.0)
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
|
EDIT_BUTTONS_BORDER_RADIUS,
|
|
)))
|
|
.with_border(Border::all(1.).with_border_fill(line_color))
|
|
.finish()
|
|
}
|
|
|
|
fn render_text(
|
|
text: &str,
|
|
styles: Option<UiComponentStyles>,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
let mut text = appearance
|
|
.ui_builder()
|
|
.wrappable_text(text.to_string(), true);
|
|
|
|
if let Some(styles) = styles {
|
|
text = text.with_style(styles);
|
|
}
|
|
|
|
text.build().finish()
|
|
}
|
|
|
|
/// Update the provided binding list by changing the binding with the given name to use a new
|
|
/// trigger.
|
|
fn update_binding_list(
|
|
name: &str,
|
|
trigger: Option<Keystroke>,
|
|
list: &mut Option<Vec<CommandBinding>>,
|
|
) {
|
|
let found_binding = list.as_mut().and_then(|vec| {
|
|
vec.iter_mut()
|
|
.find(|binding| !name.is_empty() && binding.name == name)
|
|
});
|
|
|
|
if let Some(binding) = found_binding {
|
|
binding.trigger = trigger;
|
|
}
|
|
}
|
|
|
|
fn trigger_keybinding_notifier(
|
|
name: String,
|
|
trigger: Option<Keystroke>,
|
|
ctx: &mut ViewContext<KeybindingsView>,
|
|
) {
|
|
KeybindingChangedNotifier::handle(ctx).update(ctx, move |_me, ctx| {
|
|
ctx.emit(KeybindingChangedEvent::BindingChanged {
|
|
binding_name: name,
|
|
new_trigger: trigger,
|
|
});
|
|
})
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct KeybindingsWidget {
|
|
local_only_icon_mouse_state: MouseStateHandle,
|
|
}
|
|
|
|
impl KeybindingsWidget {
|
|
fn render_description(
|
|
&self,
|
|
bindings: Option<&Vec<CommandBinding>>,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
let font_size = appearance.ui_font_size() + FONT_DELTA;
|
|
let mut description = Flex::column().with_child(render_text(
|
|
"Add your own custom keybindings to existing actions below.",
|
|
Some(UiComponentStyles {
|
|
font_size: Some(font_size),
|
|
font_color: Some(
|
|
appearance
|
|
.theme()
|
|
.sub_text_color(appearance.theme().background())
|
|
.into_solid(),
|
|
),
|
|
..Default::default()
|
|
}),
|
|
appearance,
|
|
));
|
|
|
|
if let Some(keystroke) = bindings
|
|
.and_then(|bindings| {
|
|
bindings
|
|
.iter()
|
|
.find(|&binding| binding.name == KEYBINDINGS_PAGE_SHORTCUT)
|
|
})
|
|
.and_then(|shortcut| shortcut.trigger.as_ref())
|
|
{
|
|
description = description.with_child(
|
|
Wrap::row()
|
|
.with_child(
|
|
Container::new(render_text(
|
|
"Use",
|
|
Some(UiComponentStyles {
|
|
font_size: Some(font_size),
|
|
font_color: Some(
|
|
appearance
|
|
.theme()
|
|
.sub_text_color(appearance.theme().background())
|
|
.into_solid(),
|
|
),
|
|
..Default::default()
|
|
}),
|
|
appearance,
|
|
))
|
|
.with_padding_right(10.)
|
|
.finish(),
|
|
)
|
|
.with_child(
|
|
appearance
|
|
.ui_builder()
|
|
.keyboard_shortcut(keystroke)
|
|
.with_style(UiComponentStyles {
|
|
margin: Some(Coords::default().right(5.)),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.finish(),
|
|
)
|
|
.with_child(
|
|
Container::new(render_text(
|
|
"to reference these keybindings in a side pane at anytime.",
|
|
Some(UiComponentStyles {
|
|
font_size: Some(font_size),
|
|
font_color: Some(
|
|
appearance
|
|
.theme()
|
|
.sub_text_color(appearance.theme().background())
|
|
.into_solid(),
|
|
),
|
|
..Default::default()
|
|
}),
|
|
appearance,
|
|
))
|
|
.with_padding_left(5.)
|
|
.finish(),
|
|
)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.finish(),
|
|
)
|
|
}
|
|
description.finish()
|
|
}
|
|
|
|
fn render_binding_list(
|
|
&self,
|
|
view: &KeybindingsView,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
if let Some(rows) = view.rows.as_ref() {
|
|
let rows = Flex::column().with_children(
|
|
rows.iter()
|
|
.enumerate()
|
|
.map(|(idx, row)| {
|
|
row.render(
|
|
idx,
|
|
view.modifying_row.is_some() && !row.editor_open,
|
|
view.conflict_map.has_conflict(&row.binding.trigger),
|
|
appearance,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
|
|
return ClippedScrollable::vertical(
|
|
view.clipped_scroll_state.clone(),
|
|
rows.finish(),
|
|
ScrollbarWidth::Auto,
|
|
appearance
|
|
.theme()
|
|
.disabled_text_color(appearance.theme().background())
|
|
.into(),
|
|
appearance
|
|
.theme()
|
|
.main_text_color(appearance.theme().background())
|
|
.into(),
|
|
Fill::None,
|
|
)
|
|
.finish();
|
|
}
|
|
Empty::new().finish()
|
|
}
|
|
}
|
|
|
|
impl SettingsWidget for KeybindingsWidget {
|
|
type View = KeybindingsView;
|
|
|
|
fn search_terms(&self) -> &str {
|
|
"keybindings keyboard shortcuts hotkeys"
|
|
}
|
|
|
|
fn render(
|
|
&self,
|
|
view: &Self::View,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
let local_only_icon_state = if *CloudPreferencesSettings::as_ref(app).settings_sync_enabled
|
|
{
|
|
Some(LocalOnlyIconState::Visible {
|
|
mouse_state: self.local_only_icon_mouse_state.clone(),
|
|
custom_tooltip: Some("Keyboard shortcuts are not synced to the cloud".to_string()),
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let subheader = render_sub_header(
|
|
appearance,
|
|
"Configure keyboard shortcuts",
|
|
local_only_icon_state,
|
|
);
|
|
let description = self.render_description(view.bindings.as_ref(), appearance);
|
|
|
|
Flex::column()
|
|
.with_child(subheader)
|
|
.with_child(description)
|
|
.with_child(render_columns(
|
|
Container::new(render_text(
|
|
"Command",
|
|
Some(UiComponentStyles {
|
|
font_size: Some(appearance.ui_font_size() + FONT_DELTA),
|
|
..Default::default()
|
|
}),
|
|
appearance,
|
|
))
|
|
.with_uniform_margin(20.)
|
|
.finish(),
|
|
Container::new(ChildView::new(&view.search_bar).finish())
|
|
.with_margin_right(10.)
|
|
.finish(),
|
|
0.62,
|
|
None,
|
|
Some(Coords {
|
|
top: 10.,
|
|
bottom: 0.,
|
|
right: 0.,
|
|
left: 0.,
|
|
}),
|
|
))
|
|
.with_child(Shrinkable::new(1., self.render_binding_list(view, appearance)).finish())
|
|
.finish()
|
|
}
|
|
}
|