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::{HEADER_PANELS, MissionControlState, ModalKind},
};
use ratatui::{
Frame,
layout::{Margin, Rect},
text::{Line, Span},
widgets::{Block, Clear, Paragraph, Wrap},
};
const CYCLE_TABS_LABEL: &str = "[Shift-Tab] Cycle Tabs";
const PANELS_LABEL: &str = "[Alt+`] Panels";
const CONTROL_SEPARATOR: &str = " • ";
const PIN_LABEL: &str = "[Alt-A] Pin";
const PANEL_MENU_LABELS: [&str; 4] = [
"Activities",
"Session Files",
"AI Summary",
"Background Jobs",
];
const PANEL_MENU_WIDTH: u16 = 31;
fn header_controls_width() -> u16 {
(PANELS_LABEL.len() + CONTROL_SEPARATOR.chars().count() + PIN_LABEL.len()) as u16
}
fn header_controls_area(header: Rect, state: &MissionControlState) -> Option<Rect> {
let content = super::inset_when_space_allows(header, 1, 0);
let width = header_controls_width().min(content.width);
(!state.transcript_only_layout && !content.is_empty())
.then(|| Rect::new(content.right() - width, content.y, width, 1))
}
fn header_control_area(
header: Rect,
state: &MissionControlState,
offset: u16,
width: u16,
) -> Option<Rect> {
let controls = header_controls_area(header, state)?;
let clipped = header_controls_width().saturating_sub(controls.width);
let start = offset.saturating_sub(clipped).min(controls.width);
let end = offset
.saturating_add(width)
.saturating_sub(clipped)
.min(controls.width);
(end > start).then(|| Rect::new(controls.x + start, controls.y, end - start, 1))
}
pub(crate) fn header_panels_toggle_area(header: Rect, state: &MissionControlState) -> Option<Rect> {
header_control_area(header, state, 0, PANELS_LABEL.len() as u16)
}
pub(crate) fn header_pin_toggle_area(header: Rect, state: &MissionControlState) -> Option<Rect> {
let width = PIN_LABEL.len() as u16;
header_control_area(header, state, header_controls_width() - width, width)
}
pub(crate) fn header_panel_menu_area(area: Rect, state: &MissionControlState) -> Option<Rect> {
if !state.header_panel_menu_visible() {
return None;
}
let header = super::layout_for(area, state).header;
let toggle = header_panels_toggle_area(header, state)?;
let width = PANEL_MENU_WIDTH.min(area.width);
let x = toggle.x.min(area.right().saturating_sub(width));
let card =
Rect::new(x, header.bottom(), width, HEADER_PANELS.len() as u16 + 2).intersection(area);
(!card.is_empty()).then_some(card)
}
pub(crate) fn header_panel_menu_row_at(
area: Rect,
state: &MissionControlState,
column: u16,
row: u16,
) -> Option<ModalKind> {
let rows = header_panel_menu_area(area, state)?.inner(Margin::new(1, 1));
rows.contains((column, row).into())
.then(|| HEADER_PANELS.get(usize::from(row - rows.y)).copied())
.flatten()
}
pub(super) fn draw_header_panel_menu(
frame: &mut Frame<'_>,
area: Rect,
state: &MissionControlState,
) {
let Some(card) = header_panel_menu_area(area, state) else {
return;
};
let theme = state.theme;
let block = super::bordered_block()
.border_style(theme.border(true))
.style(theme.app());
let rows = block.inner(card).inner(Margin::new(1, 0));
frame.render_widget(Clear, card);
frame.render_widget(block, card);
let lines = HEADER_PANELS
.into_iter()
.zip(PANEL_MENU_LABELS)
.enumerate()
.map(|(index, (modal, label))| {
let open = state.modals.header_modals.contains(&modal);
Line::from(vec![
Span::styled(
format!("[{}] {label:<15} ", if open { "•" } else { " " }),
if open {
theme.accent(false)
} else {
theme.muted(false)
},
),
super::hotkey_span_with_theme(theme, format!("[Alt+{}]", index + 1)),
])
})
.collect::<Vec<_>>();
frame.render_widget(Paragraph::new(lines).style(theme.app()), rows);
}
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_controls_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 control_style = |active: bool| {
if active {
theme.accent(false)
} else {
theme.muted(false)
}
};
let mut spans = hotkey_line_with_theme(
theme,
PANELS_LABEL,
control_style(state.header_panel_menu_visible()),
)
.spans;
spans.push(Span::styled(CONTROL_SEPARATOR, theme.muted(false)));
spans.extend(
hotkey_line_with_theme(
theme,
PIN_LABEL,
control_style(state.modals.header_modals_pinned),
)
.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,
);
}