Add tab switcher (#7987)
The Tab Switcher implementation (#7653): - `ctrl-tab` opens the Tab Switcher and moves selection to the previously selcted tab. It also cycles selection forward. - `ctrl-shift-tab` opens the Tab Switcher and moves selection to the last tab in the list. It also cycles selection backward. - Tab is selected and the Tab Switcher is closed on the shortcut modifier key (`ctrl` by default) release. - List items are in reverse activation history order. - The list reacts to the item changes in background (new tab, tab closed, tab title changed etc.) Intentionally not in scope of this PR: - File icons - Close buttons I will come back to these features. I think they need to be implemented in separate PRs, and be synchronized with changes in how tabs are rendered, to reuse the code as it's done in the current implementation. The Tab Switcher looks usable even without them. Known Issues: Tab Switcher doesn't react to mouse click on a list item. It's not a tab switcher specific problem, it looks like ctrl-clicks are not handled the same way in Zed as cmd-clicks. For instance, menu items can be activated with cmd-click, but don't react to ctrl-click. Since the Tab Switcher's default keybinding is `ctrl-tab`, the user can only click an item with `ctrl` pushed down, thus preventing `on_click()` from firing. fixes #7653, #7321 Release Notes: - Added Tab Switcher which is accessible via `ctrl-tab` and `ctrl-shift-tab` (#7653) (#7321) Related issues: - Unblocks #7356, I hope 😄 How it looks and works (it's only `ctrl-tab`'s and `ctrl-shift-tab`'s, no `enter`'s or mouse clicks): https://github.com/zed-industries/zed/assets/2101250/4ad4ec6a-5314-481b-8b35-7ac85e43eb92 --------- Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com> Co-authored-by: Mikayla Maki <mikayla@zed.dev>
This commit is contained in:
parent
9c22009e7b
commit
894b39a918
13 changed files with 715 additions and 56 deletions
279
crates/tab_switcher/src/tab_switcher.rs
Normal file
279
crates/tab_switcher/src/tab_switcher.rs
Normal file
|
@ -0,0 +1,279 @@
|
|||
#[cfg(test)]
|
||||
mod tab_switcher_tests;
|
||||
|
||||
use collections::HashMap;
|
||||
use gpui::{
|
||||
impl_actions, rems, Action, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView,
|
||||
Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, View, ViewContext,
|
||||
VisualContext, WeakView,
|
||||
};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use ui::{prelude::*, ListItem, ListItemSpacing};
|
||||
use util::ResultExt;
|
||||
use workspace::{
|
||||
item::ItemHandle,
|
||||
pane::{render_item_indicator, tab_details, Event as PaneEvent},
|
||||
ModalView, Pane, Workspace,
|
||||
};
|
||||
|
||||
const PANEL_WIDTH_REMS: f32 = 28.;
|
||||
|
||||
#[derive(PartialEq, Clone, Deserialize, Default)]
|
||||
pub struct Toggle {
|
||||
#[serde(default)]
|
||||
pub select_last: bool,
|
||||
}
|
||||
|
||||
impl_actions!(tab_switcher, [Toggle]);
|
||||
|
||||
pub struct TabSwitcher {
|
||||
picker: View<Picker<TabSwitcherDelegate>>,
|
||||
init_modifiers: Option<Modifiers>,
|
||||
}
|
||||
|
||||
impl ModalView for TabSwitcher {}
|
||||
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
cx.observe_new_views(TabSwitcher::register).detach();
|
||||
}
|
||||
|
||||
impl TabSwitcher {
|
||||
fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
|
||||
workspace.register_action(|workspace, action: &Toggle, cx| {
|
||||
let Some(tab_switcher) = workspace.active_modal::<Self>(cx) else {
|
||||
Self::open(action, workspace, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
tab_switcher.update(cx, |tab_switcher, cx| {
|
||||
tab_switcher
|
||||
.picker
|
||||
.update(cx, |picker, cx| picker.cycle_selection(cx))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn open(action: &Toggle, workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
|
||||
let weak_pane = workspace.active_pane().downgrade();
|
||||
workspace.toggle_modal(cx, |cx| {
|
||||
let delegate = TabSwitcherDelegate::new(action, cx.view().downgrade(), weak_pane, cx);
|
||||
TabSwitcher::new(delegate, cx)
|
||||
});
|
||||
}
|
||||
|
||||
fn new(delegate: TabSwitcherDelegate, cx: &mut ViewContext<Self>) -> Self {
|
||||
Self {
|
||||
picker: cx.new_view(|cx| Picker::nonsearchable_uniform_list(delegate, cx)),
|
||||
init_modifiers: cx.modifiers().modified().then_some(cx.modifiers()),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_modifiers_changed(
|
||||
&mut self,
|
||||
event: &ModifiersChangedEvent,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let Some(init_modifiers) = self.init_modifiers else {
|
||||
return;
|
||||
};
|
||||
if !event.modified() || !init_modifiers.is_subset_of(event) {
|
||||
self.init_modifiers = None;
|
||||
if self.picker.read(cx).delegate.matches.is_empty() {
|
||||
cx.emit(DismissEvent)
|
||||
} else {
|
||||
cx.dispatch_action(menu::Confirm.boxed_clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for TabSwitcher {}
|
||||
|
||||
impl FocusableView for TabSwitcher {
|
||||
fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
|
||||
self.picker.focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TabSwitcher {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.key_context("TabSwitcher")
|
||||
.w(rems(PANEL_WIDTH_REMS))
|
||||
.on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
|
||||
.child(self.picker.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct TabMatch {
|
||||
item_index: usize,
|
||||
item: Box<dyn ItemHandle>,
|
||||
detail: usize,
|
||||
}
|
||||
|
||||
pub struct TabSwitcherDelegate {
|
||||
select_last: bool,
|
||||
tab_switcher: WeakView<TabSwitcher>,
|
||||
selected_index: usize,
|
||||
pane: WeakView<Pane>,
|
||||
matches: Vec<TabMatch>,
|
||||
}
|
||||
|
||||
impl TabSwitcherDelegate {
|
||||
fn new(
|
||||
action: &Toggle,
|
||||
tab_switcher: WeakView<TabSwitcher>,
|
||||
pane: WeakView<Pane>,
|
||||
cx: &mut ViewContext<TabSwitcher>,
|
||||
) -> Self {
|
||||
Self::subscribe_to_updates(&pane, cx);
|
||||
Self {
|
||||
select_last: action.select_last,
|
||||
tab_switcher,
|
||||
selected_index: 0,
|
||||
pane,
|
||||
matches: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribe_to_updates(pane: &WeakView<Pane>, cx: &mut ViewContext<TabSwitcher>) {
|
||||
let Some(pane) = pane.upgrade() else {
|
||||
return;
|
||||
};
|
||||
cx.subscribe(&pane, |tab_switcher, _, event, cx| {
|
||||
match event {
|
||||
PaneEvent::AddItem { .. } | PaneEvent::RemoveItem { .. } | PaneEvent::Remove => {
|
||||
tab_switcher
|
||||
.picker
|
||||
.update(cx, |picker, cx| picker.refresh(cx))
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn update_matches(&mut self, cx: &mut WindowContext) {
|
||||
self.matches.clear();
|
||||
let Some(pane) = self.pane.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let pane = pane.read(cx);
|
||||
let mut history_indices = HashMap::default();
|
||||
pane.activation_history().iter().rev().enumerate().for_each(
|
||||
|(history_index, entity_id)| {
|
||||
history_indices.insert(entity_id, history_index);
|
||||
},
|
||||
);
|
||||
|
||||
let items: Vec<Box<dyn ItemHandle>> = pane.items().map(|item| item.boxed_clone()).collect();
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.zip(tab_details(&items, cx))
|
||||
.map(|((item_index, item), detail)| TabMatch {
|
||||
item_index,
|
||||
item: item.boxed_clone(),
|
||||
detail,
|
||||
})
|
||||
.for_each(|tab_match| self.matches.push(tab_match));
|
||||
|
||||
let non_history_base = history_indices.len();
|
||||
self.matches.sort_by(move |a, b| {
|
||||
let a_score = *history_indices
|
||||
.get(&a.item.item_id())
|
||||
.unwrap_or(&(a.item_index + non_history_base));
|
||||
let b_score = *history_indices
|
||||
.get(&b.item.item_id())
|
||||
.unwrap_or(&(b.item_index + non_history_base));
|
||||
a_score.cmp(&b_score)
|
||||
});
|
||||
|
||||
if self.matches.len() > 1 {
|
||||
if self.select_last {
|
||||
self.selected_index = self.matches.len() - 1;
|
||||
} else {
|
||||
self.selected_index = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerDelegate for TabSwitcherDelegate {
|
||||
type ListItem = ListItem;
|
||||
|
||||
fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
|
||||
"".into()
|
||||
}
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.matches.len()
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
|
||||
self.selected_index = ix;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn separators_after_indices(&self) -> Vec<usize> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn update_matches(
|
||||
&mut self,
|
||||
_raw_query: String,
|
||||
cx: &mut ViewContext<Picker<Self>>,
|
||||
) -> Task<()> {
|
||||
self.update_matches(cx);
|
||||
Task::ready(())
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<TabSwitcherDelegate>>) {
|
||||
let Some(pane) = self.pane.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(selected_match) = self.matches.get(self.selected_index()) else {
|
||||
return;
|
||||
};
|
||||
pane.update(cx, |pane, cx| {
|
||||
pane.activate_item(selected_match.item_index, true, true, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn dismissed(&mut self, cx: &mut ViewContext<Picker<TabSwitcherDelegate>>) {
|
||||
self.tab_switcher
|
||||
.update(cx, |_, cx| cx.emit(DismissEvent))
|
||||
.log_err();
|
||||
}
|
||||
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
cx: &mut ViewContext<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
let tab_match = self
|
||||
.matches
|
||||
.get(ix)
|
||||
.expect("Invalid matches state: no element for index {ix}");
|
||||
|
||||
let label = tab_match.item.tab_content(Some(tab_match.detail), true, cx);
|
||||
let indicator = render_item_indicator(tab_match.item.boxed_clone(), cx);
|
||||
|
||||
Some(
|
||||
ListItem::new(ix)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.inset(true)
|
||||
.selected(selected)
|
||||
.child(h_flex().w_full().child(label))
|
||||
.children(indicator),
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue