aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/tabs.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/tabs.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/client/shell/tabs.rs')
-rw-r--r--src/client/shell/tabs.rs386
1 files changed, 386 insertions, 0 deletions
diff --git a/src/client/shell/tabs.rs b/src/client/shell/tabs.rs
new file mode 100644
index 00000000..cf179d80
--- /dev/null
+++ b/src/client/shell/tabs.rs
@@ -0,0 +1,386 @@
+use super::*;
+
+const TAB_SCROLL_BUTTON_WIDTH: u16 = 3;
+const MIN_TAB_STRIP_WIDTH: u16 =
+ MIN_TAB_WIDTH + NEW_TAB_WIDTH + TAB_SCROLL_BUTTON_WIDTH.saturating_mul(2);
+
+pub(crate) fn render_tab_bar(
+ buffer: &mut Buffer,
+ area: Rect,
+ snapshot: &ClientShellSnapshot,
+ config: &ClientShellConfig,
+ tab_scroll: &mut usize,
+ reveal_focused_tab: &mut bool,
+ tab_drag_insert_index: Option<usize>,
+ hits: &mut ShellHitMap,
+) {
+ let palette = &config.palette;
+ buffer.set_style(area, Style::default().bg(palette.panel_bg));
+ let tabs = snapshot
+ .tabs
+ .iter()
+ .filter(|tab| Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref())
+ .collect::<Vec<_>>();
+ let desired_widths = tabs
+ .iter()
+ .map(|tab| {
+ let label = tab_label(tab);
+ display_width(&label).saturating_add(4).max(MIN_TAB_WIDTH)
+ })
+ .collect::<Vec<_>>();
+ let content = tab_bar_content_area(snapshot, area);
+ let mouse_chrome = config.mouse_capture;
+ let new_tab_width = if mouse_chrome { NEW_TAB_WIDTH } else { 0 };
+ let desired_total = desired_widths
+ .iter()
+ .copied()
+ .fold(0_u16, u16::saturating_add)
+ .saturating_add(tabs.len().saturating_sub(1).min(u16::MAX as usize) as u16)
+ .saturating_add(new_tab_width);
+ let overflow =
+ desired_total > content.width && (!mouse_chrome || content.width >= MIN_TAB_STRIP_WIDTH);
+ let available = if overflow && mouse_chrome {
+ content
+ .width
+ .saturating_sub(NEW_TAB_WIDTH)
+ .saturating_sub(TAB_SCROLL_BUTTON_WIDTH.saturating_mul(2))
+ } else {
+ content.width.saturating_sub(new_tab_width)
+ };
+ let max_scroll = max_tab_scroll(&desired_widths, available);
+ if !overflow {
+ *tab_scroll = 0;
+ } else if *reveal_focused_tab {
+ if let Some(focused) = tabs.iter().position(|tab| tab.focused) {
+ *tab_scroll = centered_tab_scroll(focused, &desired_widths, available).min(max_scroll);
+ }
+ } else {
+ *tab_scroll = (*tab_scroll).min(max_scroll);
+ }
+ *reveal_focused_tab = false;
+
+ let mut x = content.x;
+ let tab_right = if overflow && mouse_chrome {
+ hits.tab_scroll_left = Rect::new(
+ content.x,
+ content.y,
+ TAB_SCROLL_BUTTON_WIDTH.min(content.width),
+ 1,
+ );
+ put_text(
+ buffer,
+ hits.tab_scroll_left.x,
+ content.y,
+ hits.tab_scroll_left.width,
+ " < ",
+ Style::default()
+ .fg(if *tab_scroll > 0 {
+ palette.overlay1
+ } else {
+ palette.overlay0
+ })
+ .bg(palette.surface0),
+ );
+ x = hits.tab_scroll_left.right();
+ content
+ .right()
+ .saturating_sub(NEW_TAB_WIDTH + TAB_SCROLL_BUTTON_WIDTH)
+ } else {
+ content.right().saturating_sub(new_tab_width)
+ };
+
+ let mut first_visible = None;
+ let mut last_visible = None;
+ for (index, tab) in tabs.iter().enumerate().skip(*tab_scroll) {
+ let name = tab_label(tab);
+ let desired = desired_widths[index];
+ let remaining = tab_right.saturating_sub(x);
+ let width = desired.min(remaining);
+ if width == 0 {
+ break;
+ }
+ let rect = Rect::new(x, area.y, width, 1);
+ let style = if tab.focused {
+ let base = Style::default()
+ .fg(panel_contrast_fg(palette))
+ .bg(palette.accent);
+ if tab.custom_label {
+ base.add_modifier(Modifier::BOLD)
+ } else {
+ base
+ }
+ } else if tab.custom_label {
+ Style::default().fg(palette.overlay1).bg(palette.surface0)
+ } else {
+ Style::default()
+ .fg(palette.overlay0)
+ .bg(palette.surface0)
+ .add_modifier(Modifier::DIM)
+ };
+ let padding = width.saturating_sub(display_width(&name));
+ let left = padding / 2;
+ let text = format!(
+ "{empty:left$}{name}{empty:right_padding$}",
+ empty = "",
+ left = left as usize,
+ right_padding = padding.saturating_sub(left) as usize,
+ );
+ put_text(buffer, rect.x, rect.y, rect.width, &text, style);
+ hits.tabs.push((rect, tab.tab_id.clone()));
+ first_visible.get_or_insert(index);
+ last_visible = Some(index);
+ x = x.saturating_add(width + 1);
+ if width < desired {
+ break;
+ }
+ }
+
+ if overflow && mouse_chrome {
+ hits.tab_scroll_right = Rect::new(tab_right, area.y, TAB_SCROLL_BUTTON_WIDTH, 1);
+ let can_scroll_right = last_visible.is_some_and(|index| index + 1 < tabs.len());
+ put_text(
+ buffer,
+ hits.tab_scroll_right.x,
+ area.y,
+ hits.tab_scroll_right.width,
+ " > ",
+ Style::default()
+ .fg(if can_scroll_right {
+ palette.overlay1
+ } else {
+ palette.overlay0
+ })
+ .bg(palette.surface0),
+ );
+ hits.new_tab = Rect::new(
+ hits.tab_scroll_right.right(),
+ area.y,
+ content
+ .right()
+ .saturating_sub(hits.tab_scroll_right.right())
+ .min(NEW_TAB_WIDTH),
+ 1,
+ );
+ } else if mouse_chrome {
+ hits.new_tab = Rect::new(
+ x.min(content.right()),
+ area.y,
+ content.right().saturating_sub(x).min(NEW_TAB_WIDTH),
+ 1,
+ );
+ }
+ if mouse_chrome {
+ put_text(
+ buffer,
+ hits.new_tab.x,
+ area.y,
+ hits.new_tab.width,
+ " + ",
+ Style::default().fg(palette.overlay1).bg(palette.panel_bg),
+ );
+ }
+
+ if first_visible.is_some_and(|index| index > 0) {
+ let ellipsis_x = if hits.tab_scroll_left.width > 0 {
+ hits.tab_scroll_left.right()
+ } else {
+ content.x
+ };
+ put_text(
+ buffer,
+ ellipsis_x,
+ area.y,
+ u16::from(ellipsis_x < content.right()),
+ "…",
+ Style::default().fg(palette.overlay0),
+ );
+ }
+ if last_visible.is_some_and(|index| index + 1 < tabs.len()) {
+ let ellipsis_x = if hits.tab_scroll_right.width > 0 {
+ hits.tab_scroll_right.x.saturating_sub(1)
+ } else {
+ content.right().saturating_sub(1)
+ };
+ put_text(
+ buffer,
+ ellipsis_x,
+ area.y,
+ u16::from(ellipsis_x >= content.x && ellipsis_x < content.right()),
+ "…",
+ Style::default().fg(palette.overlay0),
+ );
+ }
+
+ if let Some(insert_index) = tab_drag_insert_index {
+ if let Some(indicator_x) = tab_drop_indicator_x(hits, &tabs, insert_index) {
+ put_text(
+ buffer,
+ indicator_x.min(content.right().saturating_sub(1)),
+ area.y,
+ 1,
+ "│",
+ Style::default().fg(palette.accent),
+ );
+ }
+ }
+ render_tab_bar_status(buffer, area, snapshot, palette);
+}
+
+pub(crate) fn tab_bar_status_width(snapshot: &ClientShellSnapshot) -> u16 {
+ let content = snapshot.tab_bar_right.iter().fold(0u16, |width, segment| {
+ width.saturating_add(display_width(&segment.text))
+ });
+ let separators = snapshot.tab_bar_right.len().saturating_sub(1);
+ content.saturating_add(
+ display_width(&snapshot.tab_bar_right_separator)
+ .saturating_mul(separators.min(u16::MAX as usize) as u16),
+ )
+}
+
+fn tab_bar_status_area(snapshot: &ClientShellSnapshot, area: Rect) -> Option<Rect> {
+ let width = tab_bar_status_width(snapshot);
+ if width == 0 {
+ return None;
+ }
+ let reserved = width.saturating_add(1);
+ (area.width.saturating_sub(reserved) >= MIN_TAB_STRIP_WIDTH)
+ .then(|| Rect::new(area.right().saturating_sub(width), area.y, width, 1))
+}
+
+fn tab_bar_content_area(snapshot: &ClientShellSnapshot, area: Rect) -> Rect {
+ let reserved = tab_bar_status_area(snapshot, area)
+ .map(|status| status.width.saturating_add(1))
+ .unwrap_or(0);
+ Rect {
+ width: area.width.saturating_sub(reserved),
+ ..area
+ }
+}
+
+fn render_tab_bar_status(
+ buffer: &mut Buffer,
+ area: Rect,
+ snapshot: &ClientShellSnapshot,
+ palette: &Palette,
+) {
+ let Some(status) = tab_bar_status_area(snapshot, area) else {
+ return;
+ };
+ let separator_width = display_width(&snapshot.tab_bar_right_separator);
+ let mut x = status.x;
+ for (index, segment) in snapshot.tab_bar_right.iter().enumerate() {
+ if index > 0 && separator_width > 0 {
+ put_text(
+ buffer,
+ x,
+ area.y,
+ separator_width,
+ &snapshot.tab_bar_right_separator,
+ Style::default().fg(palette.overlay0).bg(palette.panel_bg),
+ );
+ x = x.saturating_add(separator_width);
+ }
+ let width = display_width(&segment.text);
+ let style = if segment.accent {
+ Style::default()
+ .fg(panel_contrast_fg(palette))
+ .bg(palette.accent)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(palette.overlay1).bg(palette.panel_bg)
+ };
+ put_text(buffer, x, area.y, width, &segment.text, style);
+ x = x.saturating_add(width);
+ }
+}
+
+fn tab_drop_indicator_x(
+ hits: &ShellHitMap,
+ tabs: &[&ClientShellTab],
+ insert_index: usize,
+) -> Option<u16> {
+ let visible = hits
+ .tabs
+ .iter()
+ .filter_map(|(rect, tab_id)| {
+ tabs.iter()
+ .position(|tab| tab.tab_id == *tab_id)
+ .map(|index| (index, *rect))
+ })
+ .collect::<Vec<_>>();
+ let (first_index, first_rect) = *visible.first()?;
+ let (last_index, last_rect) = *visible.last()?;
+ if insert_index == 0 {
+ return Some(if first_index == 0 {
+ first_rect.x
+ } else {
+ hits.tab_scroll_left.right()
+ });
+ }
+ if let Some((_, rect)) = visible.iter().find(|(index, _)| *index == insert_index) {
+ return Some(rect.x.saturating_sub(1));
+ }
+ if insert_index >= tabs.len() {
+ return Some(if last_index + 1 >= tabs.len() {
+ last_rect.right()
+ } else {
+ hits.tab_scroll_right.x.saturating_sub(1)
+ });
+ }
+ None
+}
+
+fn centered_tab_scroll(focused: usize, widths: &[u16], available: u16) -> usize {
+ let mut best = focused;
+ let mut best_distance = u16::MAX;
+ for start in 0..=focused {
+ let before = widths
+ .iter()
+ .copied()
+ .enumerate()
+ .skip(start)
+ .take(focused.saturating_sub(start))
+ .fold(0u16, |width, (_, tab)| width.saturating_add(tab + 1));
+ if before >= available {
+ continue;
+ }
+ let focused_width = widths[focused].min(available.saturating_sub(before));
+ let center = before.saturating_mul(2).saturating_add(focused_width);
+ let distance = center.abs_diff(available);
+ if distance <= best_distance {
+ best_distance = distance;
+ best = start;
+ }
+ }
+ best
+}
+
+fn max_tab_scroll(widths: &[u16], available: u16) -> usize {
+ (0..widths.len())
+ .find(|start| last_visible_tab(*start, widths, available) == widths.len().checked_sub(1))
+ .unwrap_or(0)
+}
+
+fn last_visible_tab(start: usize, widths: &[u16], available: u16) -> Option<usize> {
+ let mut remaining = available;
+ let mut last = None;
+ for (index, width) in widths.iter().copied().enumerate().skip(start) {
+ if remaining == 0 {
+ break;
+ }
+ last = Some(index);
+ if width >= remaining {
+ break;
+ }
+ remaining = remaining.saturating_sub(width.saturating_add(1));
+ }
+ last
+}
+
+fn tab_label(tab: &ClientShellTab) -> String {
+ if tab.zoomed {
+ format!("{} Z", tab.label)
+ } else {
+ tab.label.clone()
+ }
+}