diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 809393c7f..f289f4e2f 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -193,7 +193,7 @@ impl Application { // NOTE: this isn't necessarily true anymore. If // `--vsplit` or `--hsplit` are used, the file which is // opened last is focused on. - let view_id = editor.tree.focus; + let view_id = editor.tabs.curr_tree().focus; let doc = doc_mut!(editor, &doc_id); let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true)); doc.set_selection(view_id, pos); @@ -382,7 +382,7 @@ impl Application { // reset view position in case softwrap was enabled/disabled let scrolloff = self.editor.config().scrolloff; - for (view, _) in self.editor.tree.views_mut() { + for (view, _) in self.editor.tabs.curr_tree_mut().views_mut() { let doc = &self.editor.documents[&view.doc]; view.ensure_cursor_in_view(doc, scrolloff) } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 0c26ad92e..1ae827e5c 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -49,7 +49,7 @@ use helix_view::{ theme::Style, tree, view::View, - Document, DocumentId, Editor, ViewId, + Document, DocumentId, Editor, TabId, ViewId, }; use anyhow::{anyhow, bail, ensure, Context as _}; @@ -329,6 +329,7 @@ impl MappableCommand { file_picker_in_current_buffer_directory, "Open file picker at current buffers's directory", file_picker_in_current_directory, "Open file picker at current working directory", code_action, "Perform code action", + tab_picker, "Open tab picker", buffer_picker, "Open buffer picker", jumplist_picker, "Open jumplist picker", symbol_picker, "Open symbol picker", @@ -2877,6 +2878,50 @@ fn file_picker_in_current_directory(cx: &mut Context) { cx.push_layer(Box::new(overlaid(picker))); } +fn tab_picker(cx: &mut Context) { + let current = cx.editor.tabs.focus; + + #[derive(Debug)] + struct TabMeta { + idx: usize, + id: TabId, + name: String, + is_current: bool, + } + + impl ui::menu::Item for TabMeta { + type Data = (); + + fn format(&self, _data: &Self::Data) -> Row { + let mut flags = String::new(); + if self.is_current { + flags.push('*'); + } + + Row::new([(self.idx + 1).to_string(), self.name.clone(), flags]) + } + } + + let opts = cx + .editor + .tabs + .iter_tabs() + .enumerate() + .map(|(idx, (id, tab))| TabMeta { + idx, + id, + name: tab.name.clone(), + is_current: id == current, + }) + .collect(); + + let picker = Picker::new(opts, (), |cx, meta, _action| { + cx.editor.tabs.focus = meta.id; + }); + + cx.push_layer(Box::new(overlaid(picker))); +} + fn buffer_picker(cx: &mut Context) { let current = view!(cx.editor).doc; @@ -2982,7 +3027,7 @@ fn jumplist_picker(cx: &mut Context) { } } - for (view, _) in cx.editor.tree.views_mut() { + for (view, _) in cx.editor.tabs.curr_tree_mut().views_mut() { for doc_id in view.jumps.iter().map(|e| e.0).collect::>().iter() { let doc = doc_mut!(cx.editor, doc_id); view.sync_changes(doc); @@ -3010,7 +3055,8 @@ fn jumplist_picker(cx: &mut Context) { let picker = Picker::new( cx.editor - .tree + .tabs + .curr_tree() .views() .flat_map(|(view, _)| { view.jumps @@ -3187,7 +3233,7 @@ pub fn command_palette(cx: &mut Context) { command.execute(&mut ctx); - if ctx.editor.tree.contains(focus) { + if ctx.editor.tabs.curr_tree().contains(focus) { let config = ctx.editor.config(); let mode = ctx.editor.mode(); let view = view_mut!(ctx.editor, focus); @@ -3315,7 +3361,7 @@ async fn make_format_callback( let format = format.await; let call: job::Callback = Callback::Editor(Box::new(move |editor| { - if !editor.documents.contains_key(&doc_id) || !editor.tree.contains(view_id) { + if !editor.documents.contains_key(&doc_id) || !editor.tabs.curr_tree().contains(view_id) { return; } @@ -5156,7 +5202,7 @@ fn vsplit_new(cx: &mut Context) { } fn wclose(cx: &mut Context) { - if cx.editor.tree.views().count() == 1 { + if cx.editor.tabs.curr_tree().views().count() == 1 { if let Err(err) = typed::buffers_remaining_impl(cx.editor) { cx.editor.set_error(err.to_string()); return; @@ -5170,7 +5216,8 @@ fn wclose(cx: &mut Context) { fn wonly(cx: &mut Context) { let views = cx .editor - .tree + .tabs + .curr_tree() .views() .map(|(v, focus)| (v.id, focus)) .collect::>(); diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 6a5ceae6f..7c50d8a2b 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -1174,7 +1174,7 @@ pub fn compute_inlay_hints_for_all_views(editor: &mut Editor, jobs: &mut crate:: return; } - for (view, _) in editor.tree.views() { + for (view, _) in editor.tabs.curr_tree().views() { let doc = match editor.documents.get(&view.doc) { Some(doc) => doc, None => continue, @@ -1239,7 +1239,9 @@ fn compute_inlay_hints_for_view( language_server.text_document_range_inlay_hints(doc.identifier(), range, None)?, move |editor, _compositor, response: Option>| { // The config was modified or the window was closed while the request was in flight - if !editor.config().lsp.display_inlay_hints || editor.tree.try_get(view_id).is_none() { + if !editor.config().lsp.display_inlay_hints + || editor.tabs.curr_tree().try_get(view_id).is_none() + { return; } diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index f38ae6bba..b53966656 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -76,7 +76,7 @@ fn quit(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> ensure!(args.is_empty(), ":quit takes no arguments"); // last view and we have unsaved changes - if cx.editor.tree.views().count() == 1 { + if cx.editor.tabs.curr_tree().views().count() == 1 { buffers_remaining_impl(cx.editor)? } @@ -788,7 +788,13 @@ fn quit_all_impl(cx: &mut compositor::Context, force: bool) -> anyhow::Result<() } // close all views - let views: Vec<_> = cx.editor.tree.views().map(|(view, _)| view.id).collect(); + let views: Vec<_> = cx + .editor + .tabs + .curr_tree() + .views() + .map(|(view, _)| view.id) + .collect(); for view_id in views { cx.editor.close(view_id); } @@ -1610,6 +1616,49 @@ fn tree_sitter_highlight_name( Ok(()) } +fn tab_next( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + cx.editor.tabs.focus_next(); + + Ok(()) +} + +fn tab_previous( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + cx.editor.tabs.focus_previous(); + + Ok(()) +} + +fn tab_new( + cx: &mut compositor::Context, + _args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + if event != PromptEvent::Validate { + return Ok(()); + } + + cx.editor.tabs.new_tab(); + cx.editor.new_file(Action::VerticalSplit); + + Ok(()) +} + fn vsplit( cx: &mut compositor::Context, args: &[Cow], @@ -2905,6 +2954,27 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ fun: debug_eval, signature: CommandSignature::none(), }, + TypableCommand { + name: "tab-new", + aliases: &[], + doc: "Create a new tab.", + fun: tab_new, + signature: CommandSignature::none(), + }, + TypableCommand { + name: "tab-next", + aliases: &[], + doc: "Goto next tab.", + fun: tab_next, + signature: CommandSignature::none(), + }, + TypableCommand { + name: "tab-previous", + aliases: &[], + doc: "Goto previous tab.", + fun: tab_previous, + signature: CommandSignature::none(), + }, TypableCommand { name: "vsplit", aliases: &["vs"], diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 5a3e8eed4..f33a4c3af 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -221,6 +221,7 @@ pub fn default() -> HashMap { "space" => { "Space" "f" => file_picker, "F" => file_picker_in_current_directory, + "t" => tab_picker, "b" => buffer_picker, "j" => jumplist_picker, "s" => symbol_picker, diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 7b130a38a..cf9a35df9 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -577,6 +577,49 @@ impl EditorView { Vec::new() } + /// Render tabline at the top + pub fn render_tabline(editor: &Editor, viewport: Rect, surface: &mut Surface) { + surface.clear_with( + viewport, + editor + .theme + .try_get("ui.bufferline.background") + .unwrap_or_else(|| editor.theme.get("ui.statusline")), + ); + + let bufferline_active = editor + .theme + .try_get("ui.bufferline.active") + .unwrap_or_else(|| editor.theme.get("ui.statusline.active")); + + let bufferline_inactive = editor + .theme + .try_get("ui.bufferline") + .unwrap_or_else(|| editor.theme.get("ui.statusline.inactive")); + + let mut x = viewport.x; + + let current_tab = editor.tabs.focus; + for (id, tab) in editor.tabs.iter_tabs() { + let style = if current_tab == id { + bufferline_active + } else { + bufferline_inactive + }; + + let text = format!(" {} ", tab.name); + let used_width = viewport.x.saturating_sub(x); + let rem_width = surface.area.width.saturating_sub(used_width); + x = surface + .set_stringn(x, viewport.y, text, rem_width as usize, style) + .0; + + if x >= surface.area.right() { + break; + } + } + } + /// Render bufferline at the top pub fn render_bufferline(editor: &Editor, viewport: Rect, surface: &mut Surface) { let scratch = PathBuf::from(SCRATCH_BUFFER_NAME); // default filename to use for scratch buffer @@ -1106,7 +1149,7 @@ impl EditorView { } = *event; let pos_and_view = |editor: &Editor, row, column, ignore_virtual_text| { - editor.tree.views().find_map(|(view, _focus)| { + editor.tabs.curr_tree().views().find_map(|(view, _focus)| { view.pos_at_screen_coords( &editor.documents[&view.doc], row, @@ -1118,7 +1161,7 @@ impl EditorView { }; let gutter_coords_and_view = |editor: &Editor, row, column| { - editor.tree.views().find_map(|(view, _focus)| { + editor.tabs.curr_tree().views().find_map(|(view, _focus)| { view.gutter_coords_at_screen_coords(row, column) .map(|coords| (coords, view.id)) }) @@ -1198,7 +1241,7 @@ impl EditorView { } MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { - let current_view = cxt.editor.tree.focus; + let current_view = cxt.editor.tabs.curr_tree().focus; let direction = match event.kind { MouseEventKind::ScrollUp => Direction::Backward, @@ -1207,14 +1250,14 @@ impl EditorView { }; match pos_and_view(cxt.editor, row, column, false) { - Some((_, view_id)) => cxt.editor.tree.focus = view_id, + Some((_, view_id)) => cxt.editor.tabs.curr_tree_mut().focus = view_id, None => return EventResult::Ignored(None), } let offset = config.scroll_lines.unsigned_abs(); commands::scroll(cxt, offset, direction, false); - cxt.editor.tree.focus = current_view; + cxt.editor.tabs.curr_tree_mut().focus = current_view; cxt.editor.ensure_cursor_in_view(current_view); EventResult::Consumed(None) @@ -1486,10 +1529,10 @@ impl Component for EditorView { cx.editor.resize(editor_area); if use_bufferline { - Self::render_bufferline(cx.editor, area.with_height(1), surface); + Self::render_tabline(cx.editor, area.with_height(1), surface); } - for (view, is_focused) in cx.editor.tree.views() { + for (view, is_focused) in cx.editor.tabs.curr_tree().views() { let doc = cx.editor.document(view.doc).unwrap(); self.render_view(cx.editor, doc, view, area, surface, is_focused); } diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index d7058d3ef..2023dbede 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -6,8 +6,9 @@ use crate::{ info::Info, input::KeyEvent, register::Registers, + tabs::Tabs, theme::{self, Theme}, - tree::{self, Tree}, + tree::{self}, view::ViewPosition, Align, Document, DocumentId, View, ViewId, }; @@ -944,7 +945,7 @@ use futures_util::stream::{Flatten, Once}; pub struct Editor { /// Current editing mode. pub mode: Mode, - pub tree: Tree, + pub tabs: Tabs, pub next_document_id: DocumentId, pub documents: BTreeMap, @@ -1093,7 +1094,7 @@ impl Editor { Self { mode: Mode::Normal, - tree: Tree::new(area), + tabs: Tabs::new(area), next_document_id: DocumentId::default(), documents: BTreeMap::new(), saves: HashMap::new(), @@ -1447,7 +1448,7 @@ impl Editor { } } - for (view, _) in self.tree.views_mut() { + for (view, _) in self.tabs.curr_tree_mut().views_mut() { let doc = doc_mut!(self, &view.doc); view.sync_changes(doc); view.gutters = config.gutters.clone(); @@ -1456,7 +1457,7 @@ impl Editor { } fn replace_document_in_view(&mut self, current_view: ViewId, doc_id: DocumentId) { - let view = self.tree.get_mut(current_view); + let view = self.tabs.curr_tree_mut().get_mut(current_view); view.doc = doc_id; view.offset = ViewPosition::default(); @@ -1481,6 +1482,7 @@ impl Editor { match action { Action::Replace => { let (view, doc) = current_ref!(self); + let view_id = view.id; // If the current view is an empty scratch buffer and is not displayed in any other views, delete it. // Boolean value is determined before the call to `view_mut` because the operation requires a borrow // of `self.tree`, which is mutably borrowed when `view_mut` is called. @@ -1491,9 +1493,10 @@ impl Editor { && id != doc.id // Ensure the buffer is not displayed in any other splits. && !self - .tree + .tabs + .curr_tree_mut() .traverse() - .any(|(_, v)| v.doc == doc.id && v.id != view.id); + .any(|(_, v)| v.doc == doc.id && v.id != view_id); let (view, doc) = current!(self); let view_id = view.id; @@ -1508,7 +1511,7 @@ impl Editor { self.documents.remove(&id); // Remove the scratch buffer from any jumplists - for (view, _) in self.tree.views_mut() { + for (view, _) in self.tabs.curr_tree_mut().views_mut() { view.remove_document(&id); } } else { @@ -1539,13 +1542,15 @@ impl Editor { } Action::HorizontalSplit | Action::VerticalSplit => { // copy the current view, unless there is no view yet + let focus = self.tabs.curr_tree().focus; let view = self - .tree - .try_get(self.tree.focus) + .tabs + .curr_tree_mut() + .try_get(focus) .filter(|v| id == v.doc) // Different Document .cloned() .unwrap_or_else(|| View::new(id, self.config().gutters.clone())); - let view_id = self.tree.split( + let view_id = self.tabs.curr_tree_mut().split( view, match action { Action::HorizontalSplit => Layout::Horizontal, @@ -1644,15 +1649,17 @@ impl Editor { Ok(id) } + // TODO(nrabulinski): Closing last view in a tab should move focus to previous tab pub fn close(&mut self, id: ViewId) { // Remove selections for the closed view on all documents. for doc in self.documents_mut() { doc.remove_view(id); } - self.tree.remove(id); + self.tabs.curr_tree_mut().remove(id); self._refresh(); } + // TODO(nrabulinski): Closing last view in a tab should move focus to previous tab pub fn close_document(&mut self, doc_id: DocumentId, force: bool) -> Result<(), CloseError> { let doc = match self.documents.get_mut(&doc_id) { Some(doc) => doc, @@ -1676,7 +1683,8 @@ impl Editor { } let actions: Vec = self - .tree + .tabs + .curr_tree_mut() .views_mut() .filter_map(|(view, _focus)| { view.remove_document(&doc_id); @@ -1711,7 +1719,7 @@ impl Editor { // If the document we removed was visible in all views, we will have no more views. We don't // want to close the editor just for a simple buffer close, so we need to create a new view // containing either an existing document, or a brand new document. - if self.tree.views().next().is_none() { + if self.tabs.curr_tree().views().next().is_none() { let doc_id = self .documents .iter() @@ -1719,7 +1727,7 @@ impl Editor { .next() .unwrap_or_else(|| self.new_document(Document::default(self.config.clone()))); let view = View::new(doc_id, self.config().gutters.clone()); - let view_id = self.tree.insert(view); + let view_id = self.tabs.curr_tree_mut().insert(view); let doc = doc_mut!(self, &doc_id); doc.ensure_view_init(view_id); doc.mark_as_focused(); @@ -1768,13 +1776,17 @@ impl Editor { } pub fn resize(&mut self, area: Rect) { - if self.tree.resize(area) { + if self + .tabs + .iter_tabs_mut() + .fold(false, |acc, (_, tab)| acc | tab.tree.resize(area)) + { self._refresh(); - }; + } } pub fn focus(&mut self, view_id: ViewId) { - let prev_id = std::mem::replace(&mut self.tree.focus, view_id); + let prev_id = std::mem::replace(&mut self.tabs.curr_tree_mut().focus, view_id); // if leaving the view: mode should reset and the cursor should be // within view @@ -1783,7 +1795,7 @@ impl Editor { self.ensure_cursor_in_view(view_id); // Update jumplist selections with new document changes. - for (view, _focused) in self.tree.views_mut() { + for (view, _focused) in self.tabs.curr_tree_mut().views_mut() { let doc = doc_mut!(self, &view.doc); view.sync_changes(doc); } @@ -1795,35 +1807,39 @@ impl Editor { } pub fn focus_next(&mut self) { - self.focus(self.tree.next()); + self.focus(self.tabs.curr_tree().next()); } pub fn focus_prev(&mut self) { - self.focus(self.tree.prev()); + self.focus(self.tabs.curr_tree().prev()); } pub fn focus_direction(&mut self, direction: tree::Direction) { - let current_view = self.tree.focus; - if let Some(id) = self.tree.find_split_in_direction(current_view, direction) { + let current_view = self.tabs.curr_tree().focus; + if let Some(id) = self + .tabs + .curr_tree_mut() + .find_split_in_direction(current_view, direction) + { self.focus(id) } } pub fn swap_split_in_direction(&mut self, direction: tree::Direction) { - self.tree.swap_split_in_direction(direction); + self.tabs.curr_tree_mut().swap_split_in_direction(direction); } pub fn transpose_view(&mut self) { - self.tree.transpose(); + self.tabs.curr_tree_mut().transpose(); } pub fn should_close(&self) -> bool { - self.tree.is_empty() + self.tabs.curr_tree().is_empty() } pub fn ensure_cursor_in_view(&mut self, id: ViewId) { let config = self.config(); - let view = self.tree.get_mut(id); + let view = self.tabs.curr_tree_mut().get_mut(id); let doc = &self.documents[&view.doc]; view.ensure_cursor_in_view(doc, config.scrolloff) } @@ -2075,7 +2091,7 @@ impl Editor { current_view.id } else if let Some(view_id) = doc.selections().keys().next() { let view_id = *view_id; - let view = self.tree.get_mut(view_id); + let view = self.tabs.curr_tree_mut().get_mut(view_id); view.sync_changes(doc); view_id } else { diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index 14b6e1ce8..ece37ee01 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -13,6 +13,7 @@ pub mod info; pub mod input; pub mod keyboard; pub mod register; +pub mod tabs; pub mod theme; pub mod tree; pub mod view; @@ -38,6 +39,7 @@ impl std::fmt::Display for DocumentId { slotmap::new_key_type! { pub struct ViewId; + pub struct TabId; } pub enum Align { diff --git a/helix-view/src/macros.rs b/helix-view/src/macros.rs index ee9cd4111..3570b5618 100644 --- a/helix-view/src/macros.rs +++ b/helix-view/src/macros.rs @@ -22,7 +22,8 @@ macro_rules! current { #[macro_export] macro_rules! current_ref { ($editor:expr) => {{ - let view = $editor.tree.get($editor.tree.focus); + let tree = $editor.tabs.curr_tree(); + let view = tree.get(tree.focus); let doc = &$editor.documents[&view.doc]; (view, doc) }}; @@ -45,10 +46,12 @@ macro_rules! doc_mut { #[macro_export] macro_rules! view_mut { ($editor:expr, $id:expr) => {{ - $editor.tree.get_mut($id) + let tree = $editor.tabs.curr_tree_mut(); + tree.get_mut($id) }}; ($editor:expr) => {{ - $editor.tree.get_mut($editor.tree.focus) + let tree = $editor.tabs.curr_tree_mut(); + tree.get_mut(tree.focus) }}; } @@ -57,10 +60,12 @@ macro_rules! view_mut { #[macro_export] macro_rules! view { ($editor:expr, $id:expr) => {{ - $editor.tree.get($id) + let tree = $editor.tabs.curr_tree(); + tree.get($id) }}; ($editor:expr) => {{ - $editor.tree.get($editor.tree.focus) + let tree = $editor.tabs.curr_tree(); + tree.get(tree.focus) }}; } diff --git a/helix-view/src/tabs.rs b/helix-view/src/tabs.rs new file mode 100644 index 000000000..a33dad415 --- /dev/null +++ b/helix-view/src/tabs.rs @@ -0,0 +1,79 @@ +use slotmap::HopSlotMap; + +use crate::{graphics::Rect, tree::Tree, TabId}; + +#[derive(Debug)] +pub struct Tab { + pub name: String, + pub tree: Tree, +} + +#[derive(Debug)] +pub struct Tabs { + pub focus: TabId, + tabs: HopSlotMap, +} + +impl Tabs { + #[inline] + pub fn new(area: Rect) -> Self { + let mut tabs = HopSlotMap::with_key(); + let tab = Tab { + name: "Tab 0".to_string(), + tree: Tree::new(area), + }; + let focus = tabs.insert(tab); + Self { focus, tabs } + } + + #[inline] + pub fn curr_tree_mut(&mut self) -> &mut Tree { + &mut self.tabs.get_mut(self.focus).unwrap().tree + } + + #[inline] + pub fn curr_tree(&self) -> &Tree { + &self.tabs.get(self.focus).unwrap().tree + } + + #[inline] + pub fn iter_tabs_mut(&mut self) -> impl Iterator { + self.tabs.iter_mut() + } + + #[inline] + pub fn iter_tabs(&self) -> impl Iterator { + self.tabs.iter() + } + + #[inline] + pub fn new_tab(&mut self) -> TabId { + let area = self.curr_tree().area(); + let new_tab = Tab { + name: format!("Tab {}", self.tabs.len()), + tree: Tree::new(area), + }; + let new_focus = self.tabs.insert(new_tab); + self.focus = new_focus; + new_focus + } + + #[inline] + pub fn focus_next(&mut self) -> TabId { + let curr = self.focus; + let mut iter = self.tabs.keys().skip_while(|id| *id != curr); + iter.next(); + let id = iter.next().or_else(|| self.tabs.keys().next()).unwrap(); + self.focus = id; + id + } + + #[inline] + pub fn focus_previous(&mut self) -> TabId { + let curr = self.focus; + let iter = self.tabs.keys().take_while(|id| *id != curr); + let id = iter.last().or_else(|| self.tabs.keys().last()).unwrap(); + self.focus = id; + id + } +}