| 1 | //! Frozen current owning renderer, used only for adoption acceptance. |
| 2 | use super::*; |
| 3 | |
| 4 | #[derive(Debug, Clone)] |
| 5 | struct DockTab { |
| 6 | target: DockTabTarget, |
| 7 | label: std::borrow::Cow<'static, str>, |
| 8 | count: usize, |
| 9 | } |
| 10 | |
| 11 | pub(super) fn render_dock_tabs(frame: &mut Frame, area: Rect, app: &mut App) { |
| 12 | let width = usize::from(area.width); |
| 13 | let mut entries = Vec::new(); |
| 14 | for panel in RailPanel::ORDER { |
| 15 | let count = dock_tab_count(app, panel); |
| 16 | let useful = count.is_some_and(|count| count > 0) |
| 17 | || super::super::views::view_always_has_content(panel); |
| 18 | if useful || panel == app.work_surface.panel { |
| 19 | entries.push(DockTab { |
| 20 | target: DockTabTarget::Panel(panel), |
| 21 | label: match panel { |
| 22 | RailPanel::Tasks => "Tasks", |
| 23 | RailPanel::Agents => "Fleet", |
| 24 | RailPanel::Background => "Jobs", |
| 25 | RailPanel::Files => "Files", |
| 26 | RailPanel::Notepad => "Notes", |
| 27 | RailPanel::Context => "Context", |
| 28 | RailPanel::Git => "Git", |
| 29 | RailPanel::Price => "Cost", |
| 30 | } |
| 31 | .into(), |
| 32 | count: count.unwrap_or(0), |
| 33 | }); |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | let close_mark = if crate::tui::color_compat::ascii_safe_enabled() { |
| 38 | "x" |
| 39 | } else { |
| 40 | "×" |
| 41 | }; |
| 42 | // #6502: name Esc beside the close control only while Esc really closes |
| 43 | // the dock — the dock owns keyboard focus and has something to close |
| 44 | // (`input::handle_key`). Otherwise Esc belongs to the composer and stops |
| 45 | // the running turn, which the posture bar already says with the turn |
| 46 | // status; a bare `×` here keeps the two from reading as one shortcut. |
| 47 | let esc_closes = app.work_surface.focused |
| 48 | && !super::super::interaction::opened_detail_on_screen(app) |
| 49 | && (app.work_surface.explicit_view || !visible_rows_for_panel(app).is_empty()); |
| 50 | let close = if esc_closes && area.width >= 60 { |
| 51 | format!(" Esc {close_mark} ") |
| 52 | } else { |
| 53 | format!(" {close_mark} ") |
| 54 | }; |
| 55 | let close_width = close.width().min(width); |
| 56 | let mut show_counts = true; |
| 57 | let fits = |tabs: &[DockTab], counts: bool| { |
| 58 | tabs.iter() |
| 59 | .map(|tab| { |
| 60 | UnicodeWidthStr::width(tab.label.as_ref()) |
| 61 | + if counts && tab.count > 0 { |
| 62 | 1 + tab.count.to_string().len() |
| 63 | } else { |
| 64 | 0 |
| 65 | } |
| 66 | + 2 |
| 67 | }) |
| 68 | .sum::<usize>() |
| 69 | .saturating_add(tabs.len().saturating_sub(1).saturating_mul(2)) |
| 70 | .saturating_add(close_width + 2) |
| 71 | <= width |
| 72 | }; |
| 73 | if !fits(&entries, true) { |
| 74 | show_counts = false; |
| 75 | } |
| 76 | // Shed from the right (price, git, context, notepad, files… in reverse |
| 77 | // cycle order), never the active tab: a narrow dock keeps the work views. |
| 78 | while !fits(&entries, show_counts) && entries.len() > 1 { |
| 79 | let remove = entries |
| 80 | .iter() |
| 81 | .rposition(|tab| tab.target != DockTabTarget::Panel(app.work_surface.panel)); |
| 82 | let Some(index) = remove else { break }; |
| 83 | entries.remove(index); |
| 84 | } |
| 85 | |
| 86 | let tab_y = if app.work_surface.effective_placement == WorkSurfacePlacement::Bottom { |
| 87 | area.y |
| 88 | .saturating_add(1) |
| 89 | .min(area.bottom().saturating_sub(1)) |
| 90 | } else { |
| 91 | area.y |
| 92 | }; |
| 93 | let tab_area = Rect { |
| 94 | x: area.x, |
| 95 | y: tab_y, |
| 96 | width: area.width, |
| 97 | height: 1, |
| 98 | }; |
| 99 | let close_area = Rect { |
| 100 | x: tab_area.right().saturating_sub(close_width as u16), |
| 101 | y: tab_y, |
| 102 | width: close_width as u16, |
| 103 | height: 1, |
| 104 | }; |
| 105 | app.work_surface.dock_tabs.clear(); |
| 106 | for tab in &entries { |
| 107 | let label = if show_counts && tab.count > 0 { |
| 108 | format!("{} {}", tab.label, tab.count) |
| 109 | } else { |
| 110 | tab.label.to_string() |
| 111 | }; |
| 112 | let tab_width = u16::try_from(UnicodeWidthStr::width(label.as_str()).saturating_add(2)) |
| 113 | .unwrap_or(u16::MAX) |
| 114 | .min(tab_area.width); |
| 115 | let x = tab_area.x.saturating_add( |
| 116 | app.work_surface |
| 117 | .dock_tabs |
| 118 | .last() |
| 119 | .map(|hitbox| hitbox.area.right().saturating_sub(tab_area.x) + 2) |
| 120 | .unwrap_or(1), |
| 121 | ); |
| 122 | if x.saturating_add(tab_width) > close_area.x { |
| 123 | break; |
| 124 | } |
| 125 | let hitbox = Rect { |
| 126 | x, |
| 127 | y: tab_y, |
| 128 | width: tab_width, |
| 129 | height: 1, |
| 130 | }; |
| 131 | let active = tab.target == DockTabTarget::Panel(app.work_surface.panel); |
| 132 | let pressed = app.work_surface.pressed_tab == Some(tab.target); |
| 133 | let hovered = app.work_surface.hovered_tab == Some(tab.target); |
| 134 | let style = if active || pressed { |
| 135 | Style::default() |
| 136 | .fg(app.ui_theme.text_body) |
| 137 | .bg(app.ui_theme.selection_bg) |
| 138 | .add_modifier(Modifier::BOLD) |
| 139 | } else if hovered { |
| 140 | Style::default() |
| 141 | .fg(app.ui_theme.text_body) |
| 142 | .bg(app.ui_theme.elevated_bg) |
| 143 | .add_modifier(Modifier::UNDERLINED) |
| 144 | } else { |
| 145 | chrome_style(&app.ui_theme, ChromeInk::Metadata) |
| 146 | }; |
| 147 | Paragraph::new(Line::from(Span::styled(format!(" {label} "), style))) |
| 148 | .render(hitbox, frame.buffer_mut()); |
| 149 | app.work_surface.dock_tabs.push(DockTabHitbox { |
| 150 | target: tab.target, |
| 151 | area: hitbox, |
| 152 | }); |
| 153 | } |
| 154 | let close_style = if app.work_surface.hovered_tab == Some(DockTabTarget::Close) { |
| 155 | chrome_style(&app.ui_theme, ChromeInk::Info) |
| 156 | .bg(app.ui_theme.elevated_bg) |
| 157 | .add_modifier(Modifier::UNDERLINED) |
| 158 | } else { |
| 159 | chrome_style(&app.ui_theme, ChromeInk::MetadataHint) |
| 160 | }; |
| 161 | Paragraph::new(Line::from(Span::styled(close, close_style))) |
| 162 | .render(close_area, frame.buffer_mut()); |
| 163 | app.work_surface.dock_tabs.push(DockTabHitbox { |
| 164 | target: DockTabTarget::Close, |
| 165 | area: close_area, |
| 166 | }); |
| 167 | } |
| 168 |