use super::{hotkey_line_with_theme, render_scroll_view_persistent_with_theme};
#[cfg(any(test, debug_assertions))]
use crate::tui::PADDED_INLINE_SEPARATOR;
use crate::tui::{input, state::MissionControlState};
use ratatui::{
Frame,
layout::Rect,
text::{Line, Span},
widgets::{Block, Paragraph, Wrap},
};
const CYCLE_TABS_LABEL: &str = "[Shift-Tab] Cycle Tabs";
const PANEL_SHORTCUTS: &str = "[Alt+1-3] ";
const PANEL_TOGGLE_LABELS: [&str; 3] = ["Activities", "Files", "Summary"];
const PIN_LABEL: &str = "[Alt-A] Pin";
fn header_panels_width() -> u16 {
(PANEL_SHORTCUTS.len()
+ PANEL_TOGGLE_LABELS
.iter()
.map(|label| label.len() + 4)
.sum::<usize>()
+ 4
+ 3
+ PIN_LABEL.len()) as u16
}
fn header_panels_area(header: Rect, state: &MissionControlState) -> Option<Rect> {
let content = super::inset_when_space_allows(header, 1, 0);
let width = header_panels_width().min(content.width);
(!state.transcript_only_layout && !content.is_empty())
.then(|| Rect::new(content.right() - width, content.y, width, 1))
}
fn header_panel_control_area(
header: Rect,
state: &MissionControlState,
offset: u16,
width: u16,
) -> Option<Rect> {
let panels = header_panels_area(header, state)?;
let clipped = header_panels_width().saturating_sub(panels.width);
let start = offset.saturating_sub(clipped).min(panels.width);
let end = offset
.saturating_add(width)
.saturating_sub(clipped)
.min(panels.width);
(end > start).then(|| Rect::new(panels.x + start, panels.y, end - start, 1))
}
pub(crate) fn header_toggle_areas(header: Rect, state: &MissionControlState) -> [Option<Rect>; 3] {
let mut offset = PANEL_SHORTCUTS.len() as u16;
PANEL_TOGGLE_LABELS.map(|label| {
let width = label.len() as u16 + 4;
let area = header_panel_control_area(header, state, offset, width);
offset += width + 2;
area
})
}
pub(crate) fn header_pin_toggle_area(header: Rect, state: &MissionControlState) -> Option<Rect> {
let width = PIN_LABEL.len() as u16;
header_panel_control_area(header, state, header_panels_width() - width, width)
}
pub(super) fn draw_header(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
let theme = state.theme;
frame.render_widget(Block::default().style(theme.pane()), area);
let content = super::inset_when_space_allows(area, 1, 0);
let title_style = theme.title(state.is_transcript_focused());
let panels = header_panels_area(area, state);
for (row, title) in [
Line::styled(format!("Session: {} ", state.footer_session), title_style),
Line::styled(branch_text(state), theme.muted(false)),
]
.into_iter()
.enumerate()
.take(usize::from(content.height))
{
let y = content.y.saturating_add(row as u16);
let controls_left = if row == 0 {
panels.map(|panels| panels.x)
} else if !state.transcript_only_layout {
Some(
content
.right()
.saturating_sub(CYCLE_TABS_LABEL.len() as u16)
.max(content.x),
)
} else {
None
};
let title_right =
controls_left.map_or(content.right(), |x| x.saturating_sub(1).max(content.x));
frame.render_widget(
Paragraph::new(title).style(theme.pane()),
Rect::new(content.x, y, title_right.saturating_sub(content.x), 1),
);
}
if let Some(panels) = panels {
let pin_style = if state.modals.header_modals_pinned {
theme.accent(false)
} else {
theme.muted(false)
};
let mut spans = vec![Span::styled(
PANEL_SHORTCUTS,
super::hotkey_style_with_theme(theme),
)];
for (index, (label, modal)) in PANEL_TOGGLE_LABELS
.into_iter()
.zip([
super::super::state::ModalKind::ActivityTree,
super::super::state::ModalKind::SessionFiles,
super::super::state::ModalKind::Summary,
])
.enumerate()
{
if index > 0 {
spans.push(Span::styled(", ", theme.muted(false)));
}
let open = state.modals.header_modals.contains(&modal);
spans.push(Span::styled(
format!("[{}] {label}", if open { "•" } else { " " }),
if open {
theme.accent(false)
} else {
theme.muted(false)
},
));
}
spans.push(Span::styled(" • ", theme.muted(false)));
spans.extend(hotkey_line_with_theme(theme, PIN_LABEL, pin_style).spans);
frame.render_widget(
Line::from(spans).right_aligned().style(theme.pane()),
panels,
);
}
if !state.transcript_only_layout && content.height > 1 {
let width = (CYCLE_TABS_LABEL.len() as u16).min(content.width);
frame.render_widget(
hotkey_line_with_theme(theme, CYCLE_TABS_LABEL, title_style)
.right_aligned()
.style(theme.pane()),
Rect::new(content.right() - width, content.y + 1, width, 1),
);
}
if content.height > 2 {
frame.render_widget(
Paragraph::new(Line::styled(
"─".repeat(usize::from(content.width)),
ratatui::style::Style::new().fg(theme.border_alternate()),
)),
Rect::new(content.x, content.y.saturating_add(2), content.width, 1),
);
}
}
pub(super) fn branch_text(state: &MissionControlState) -> String {
state
.footer_git_branch
.as_deref()
.filter(|branch| !branch.is_empty())
.map(|branch| format!("Branch: {branch}"))
.unwrap_or_default()
}
pub(super) fn version_title(state: &MissionControlState) -> Line<'static> {
let accent = state.theme.accent(false);
let mut spans = Vec::new();
#[cfg(any(test, debug_assertions))]
if let Some(perf) = state.perf.footer_text() {
spans.push(Span::styled(
format!("{perf}{PADDED_INLINE_SEPARATOR}"),
accent,
));
}
if state.available_update_version.is_some() {
spans.push(Span::styled(
"(/update ready) ",
accent.fg(state.theme.text_command()),
));
}
spans.push(Span::styled(
format!("MAGI-CODE v{}", env!("CARGO_PKG_VERSION")),
accent,
));
Line::from(spans)
}
pub(super) fn draw_help(frame: &mut Frame<'_>, area: Rect, state: &MissionControlState) {
let theme = state.theme;
let inner = crate::tui::modal_container::draw_modal_container(
frame,
area,
"Help",
"[PgUp]/[PgDn] scroll",
theme,
Some("[Esc] close"),
);
let text_area = super::overlay_text_area(inner);
let help_text = input::help_text();
let wrap_width = super::scroll_view_content_width(text_area).max(1);
let scroll = state.help_render_offset(text_area.height, wrap_width, help_text);
render_scroll_view_persistent_with_theme(
frame,
text_area,
super::usize_to_u16_saturating(scroll),
super::usize_to_u16_saturating(crate::tui::transcript::ratatui_wrapped_visual_rows(
help_text, wrap_width,
)),
Paragraph::new(help_text)
.style(theme.app())
.wrap(Wrap { trim: false }),
&state.scroll_views.help,
theme,
);
}