diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/tabs.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (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.rs | 386 |
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() + } +} |
