use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Row, Table, Wrap},
};
use trusty_mpm::client::ManagedSessionSummary;
use crate::commands::session_picker_render::{StateColor, session_color};
use super::layout::{self, Column, MARKER_WIDTH};
use super::state::{Mode, Severity, TuiState};
const FOOTER: &str =
"↑↓ move · Enter open · n new · r rename · d delete · R refresh · ? keys · q quit";
pub(crate) fn render(frame: &mut Frame, sessions: &[ManagedSessionSummary], state: &mut TuiState) {
let area = frame.area();
let status_text = status_lines(state, area.width);
let status_rows = (status_text.len().max(1) as u16)
.min(area.height.saturating_sub(3).max(1))
.max(1);
let [title, body, status, footer] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(status_rows),
Constraint::Length(1),
])
.areas(area);
frame.render_widget(
Paragraph::new(title_line(sessions, state))
.style(Style::default().add_modifier(Modifier::BOLD)),
title,
);
render_table(frame, body, sessions, state);
frame.render_widget(Paragraph::new(status_text), status);
frame.render_widget(
Paragraph::new(FOOTER).style(Style::default().fg(Color::DarkGray)),
footer,
);
match state.mode() {
Mode::Browse => {}
Mode::Help => overlay(frame, area, "keys", help_body()),
Mode::Confirm {
index,
force,
stop_first,
typed,
} => {
let name = sessions.get(*index).map_or("?", |s| s.name.as_str());
let body = confirm_body(name, *force, *stop_first, typed);
overlay(frame, area, "delete", body);
}
Mode::Rename { index, typed } => {
let was = sessions.get(*index).map_or("?", |s| s.name.as_str());
overlay(frame, area, "rename", rename_body(was, typed));
}
Mode::New(flow) => {
let inner = overlay_rect(area).width.saturating_sub(2) as usize;
overlay(frame, area, "new session", new_session_body(flow, inner));
}
}
}
fn title_line(sessions: &[ManagedSessionSummary], state: &TuiState) -> String {
if sessions.is_empty() {
return "tm ls — no sessions".to_string();
}
format!(
"tm ls — {} of {} session(s)",
state.selected() + 1,
sessions.len()
)
}
fn status_lines(state: &TuiState, width: u16) -> Vec<Line<'static>> {
let Some((text, severity)) = state.message() else {
return Vec::new();
};
let color = match severity {
Severity::Error => Color::Red,
Severity::Info => Color::Green,
};
layout::wrap_message(text, width as usize, layout::STATUS_MAX_LINES)
.into_iter()
.map(|line| Line::from(Span::styled(line, Style::default().fg(color))))
.collect()
}
fn render_table(
frame: &mut Frame,
area: Rect,
sessions: &[ManagedSessionSummary],
state: &mut TuiState,
) {
if area.height == 0 || area.width == 0 {
return;
}
let columns = layout::visible_columns(area.width);
let rows_height = area.height.saturating_sub(1) as usize;
let (start, len) = layout::visible_window(
sessions.len(),
state.selected(),
rows_height,
state.scroll(),
);
state.set_scroll(start);
let rows: Vec<Row> = sessions[start..start + len]
.iter()
.enumerate()
.map(|(offset, session)| {
let selected = start + offset == state.selected();
row(&columns, session, selected, state.use_color())
})
.collect();
let header = Row::new(
std::iter::once(String::new())
.chain(columns.iter().map(|c| layout::header(*c).to_string()))
.collect::<Vec<_>>(),
)
.style(Style::default().fg(Color::DarkGray));
let widths: Vec<Constraint> = std::iter::once(Constraint::Length(MARKER_WIDTH))
.chain(columns.iter().map(|c| match c {
Column::Name => Constraint::Min(layout::width(Column::Name)),
other => Constraint::Length(layout::width(*other)),
}))
.collect();
frame.render_widget(
Table::new(rows, widths).header(header).column_spacing(1),
area,
);
}
fn row(
columns: &[Column],
session: &ManagedSessionSummary,
selected: bool,
use_color: bool,
) -> Row<'static> {
let cells: Vec<String> = std::iter::once(if selected {
"▸".to_string()
} else {
String::new()
})
.chain(columns.iter().map(|c| layout::cell(*c, session)))
.collect();
let style = if selected {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if use_color {
state_style(session_color(session))
} else {
Style::default()
};
Row::new(cells).style(style)
}
pub(crate) fn state_style(color: StateColor) -> Style {
let style = Style::default();
match color {
StateColor::Green => style.fg(Color::Green),
StateColor::AttachedCyan => style.fg(Color::Cyan).add_modifier(Modifier::BOLD),
StateColor::Dim => style.add_modifier(Modifier::DIM),
StateColor::Red => style.fg(Color::Red),
StateColor::Yellow => style.fg(Color::Yellow),
StateColor::Blue => style.fg(Color::Blue),
StateColor::Magenta => style.fg(Color::Magenta),
StateColor::Cyan => style.fg(Color::Cyan),
StateColor::Plain => style,
}
}
fn overlay(frame: &mut Frame, area: Rect, title: &str, body: Vec<Line<'static>>) {
let popup = overlay_rect(area);
frame.render_widget(Clear, popup);
frame.render_widget(
Paragraph::new(body).wrap(Wrap { trim: false }).block(
Block::default()
.borders(Borders::ALL)
.title(format!(" {title} ")),
),
popup,
);
}
fn overlay_rect(area: Rect) -> Rect {
let [_, middle, _] = Layout::vertical([
Constraint::Percentage(20),
Constraint::Percentage(60),
Constraint::Percentage(20),
])
.areas(area);
let [_, popup, _] = Layout::horizontal([
Constraint::Percentage(10),
Constraint::Percentage(80),
Constraint::Percentage(10),
])
.areas(middle);
popup
}
fn fit(text: &str, width: usize) -> String {
if text.chars().count() <= width {
return text.to_string();
}
if width < 2 {
return String::new();
}
let mut cut: String = text.chars().take(width - 1).collect();
cut.push('…');
cut
}
fn confirm_body(name: &str, force: bool, stop_first: bool, typed: &str) -> Vec<Line<'static>> {
let ask = if force {
format!("'{name}' is RUNNING. Type the word force, then Enter, to delete it.")
} else if stop_first {
crate::commands::picker_delete::errored_confirm_ask(name)
} else {
format!("Delete '{name}'? Type y, then Enter.")
};
vec![
Line::from(ask),
Line::from(String::new()),
Line::from(format!("> {typed}▌")),
Line::from("Esc cancels."),
]
}
fn rename_body(was: &str, typed: &str) -> Vec<Line<'static>> {
vec![
Line::from(format!("Rename '{was}' to:")),
Line::from(String::new()),
Line::from(format!("> {typed}▌")),
Line::from("Enter applies, Esc cancels."),
]
}
fn new_session_body(flow: &super::new_session::NewSessionFlow, width: usize) -> Vec<Line<'static>> {
if let Some(naming) = flow.naming() {
let preview = naming
.preview()
.unwrap_or_else(|| "(type a letter or digit)".to_string());
return vec![
Line::from(fit(
&format!("Name the new session in {}:", naming.label()),
width,
)),
Line::from(String::new()),
Line::from(format!("> {}▌", naming.typed())),
Line::from(fit(&format!("session: {preview}"), width)),
Line::from("Enter creates it, Esc goes back to the list."),
];
}
if let Some(typed) = flow.typed() {
return vec![
Line::from("Path, clone URL or owner/repo tm does not have yet:"),
Line::from(String::new()),
Line::from(format!("> {typed}▌")),
Line::from("Enter registers it and starts a session, Esc cancels."),
];
}
let mut lines = vec![
Line::from("Start a new session in:"),
Line::from(String::new()),
];
lines.extend(flow.rows().iter().map(|r| Line::from(fit(r, width))));
let hint = new_session_hint(flow);
if !hint.is_empty() {
lines.push(Line::from(fit(&hint, width)));
}
lines.push(Line::from(String::new()));
lines.push(Line::from(
"Type to filter, or type owner/repo or a clone URL.",
));
lines.push(Line::from(
"Enter confirms, Ctrl-N names the session first, Esc cancels.",
));
lines
}
fn new_session_hint(flow: &super::new_session::NewSessionFlow) -> String {
let mut parts: Vec<String> = flow.position().into_iter().collect();
if !flow.filter().is_empty() {
parts.push(format!("filter: {}", flow.filter()));
}
parts.join(" · ")
}
fn help_body() -> Vec<Line<'static>> {
[
"↑ / k, ↓ / j move the selection",
"g / G first / last row",
"PgUp / PgDn move ten rows",
"Enter open (resume + attach) the selected session",
"n new session — pick a project, or type a path, a clone",
" URL, owner/repo, or domain/owner/repo",
"Ctrl-N in that project list: name the new session first",
"r rename it",
"d delete it, with a confirm step",
"R refresh the list from the daemon",
"q / Esc quit",
"",
"Any key closes this help.",
]
.into_iter()
.map(|l| Line::from(l.to_string()))
.collect()
}