use bmux_tui::component::{Component, Constraints, LayoutCx};
use bmux_tui::geometry::Rect;
use bmux_tui::paint::{LocalRect, PaintCx};
use bmux_tui::prelude::{Line, Span, Style};
use bmux_tui::style::Modifier;
use bmux_tui_components::action_row::{
ActionButton, ActionRowComponent, ActionRowState, ActionRowStyles,
};
use super::picker_render::{
picker_list_area, render_picker_chrome, render_picker_list_with_selection, render_picker_status,
};
use super::render::TuiTheme;
use super::session_picker::{SessionPickerApp, SessionPickerMode};
#[cfg(test)]
pub fn render_picker(app: &mut SessionPickerApp, frame: &mut PaintCx<'_, '_>, theme: TuiTheme) {
render_picker_with_keymap(
app,
frame,
theme,
&super::keymap::BmuxKeyMap::from_config(&bcode_config::TuiConfig::default()),
);
}
pub fn render_picker_with_keymap(
app: &mut SessionPickerApp,
frame: &mut PaintCx<'_, '_>,
theme: TuiTheme,
keymap: &super::keymap::BmuxKeyMap,
) {
app.set_list_area(None);
let mode = app.mode();
let Some((inner, list_y)) = render_picker_chrome(
if app.all_directories() {
" Sessions · All directories (native) "
} else {
" Sessions · This directory "
},
&header_line_with_keymap(mode, keymap),
app.active_input_mut(),
input_placeholder(mode),
frame,
theme,
) else {
return;
};
if mode == SessionPickerMode::Filter && inner.height > 1 {
render_scope_controls(app.all_directories(), inner, frame, theme, keymap);
}
let bottom_y = render_picker_status(inner, app.status(), theme.muted, frame, theme);
if let Some((session, warnings)) = app.last_import()
&& !warnings.is_empty()
{
let warning_text = format_import_warnings(session, warnings);
let warning_y = bottom_y.saturating_sub(1);
if warning_y > list_y {
frame.write_line_with_fallback_style(
LocalRect::terminal(bmux_tui::geometry::Rect::new(
inner.x.saturating_add(1),
warning_y,
inner.width.saturating_sub(2),
1,
)),
&Line::from_spans(vec![Span::styled(warning_text, theme.muted)]),
Style::new(),
);
}
}
let preview_reserved = if mode == SessionPickerMode::TranscriptSearch {
let preview_y = bottom_y.saturating_sub(1);
if preview_y > list_y {
frame.write_line_with_fallback_style(
LocalRect::terminal(bmux_tui::geometry::Rect::new(
inner.x.saturating_add(1),
preview_y,
inner.width.saturating_sub(2),
1,
)),
&Line::from_spans(vec![
Span::styled("Preview: ", Style::new().add_modifier(Modifier::BOLD)),
Span::styled(app.search_preview(), theme.muted),
]),
Style::new(),
);
1
} else {
0
}
} else {
0
};
let bottom_y = bottom_y.saturating_sub(preview_reserved);
let Some(list_area) = picker_list_area(inner, list_y, bottom_y) else {
return;
};
app.set_list_area(Some(list_area));
let items = app.list_items(theme.muted);
render_picker_list_with_selection(
&items,
app.list_render_state(list_area.height),
list_area,
frame,
theme,
theme.raised.patch(theme.focused),
);
}
fn render_scope_controls(
all: bool,
inner: Rect,
frame: &mut PaintCx<'_, '_>,
theme: TuiTheme,
keymap: &super::keymap::BmuxKeyMap,
) {
let next = format!(
"Next page {}",
keymap.hint("session_picker", "tui.session.catalogNextPage")
);
let restart = format!(
"Restart {}",
keymap.hint("session_picker", "tui.session.catalogRestart")
);
let buttons = [
ActionButton::new(
"local",
if all {
"This directory"
} else {
"● This directory"
},
),
ActionButton::new(
"all",
if all {
"● All directories"
} else {
"All directories"
},
),
ActionButton::new("next", &next),
ActionButton::new("restart", &restart),
];
let state = std::cell::Cell::new(ActionRowState::new());
let component =
ActionRowComponent::new("session.scope", &buttons, &state).styles(ActionRowStyles {
normal: theme.text,
focused: theme.focused,
hovered: theme.focused,
pressed: theme.focused,
disabled: theme.muted,
});
let area = Rect::new(inner.x, inner.y.saturating_add(1), inner.width, 1);
let layout = component.layout(Constraints::tight(area.size()), &mut LayoutCx::new());
frame.with_child(
i32::from(area.x),
i64::from(area.y),
LocalRect::new(0, 0, area.width, 1),
|cx| component.paint(&layout, cx),
);
}
const fn input_placeholder(mode: SessionPickerMode) -> &'static str {
match mode {
SessionPickerMode::Filter | SessionPickerMode::DeleteConfirm => "Filter sessions",
SessionPickerMode::TranscriptSearch => "Transcript query",
SessionPickerMode::Rename => "New session name",
}
}
#[cfg(test)]
fn header_line(mode: SessionPickerMode) -> Line {
header_line_with_keymap(
mode,
&super::keymap::BmuxKeyMap::from_config(&bcode_config::TuiConfig::default()),
)
}
fn header_line_with_keymap(mode: SessionPickerMode, keymap: &super::keymap::BmuxKeyMap) -> Line {
let scope = match mode {
SessionPickerMode::Filter => "session_picker",
SessionPickerMode::Rename => "session_rename",
SessionPickerMode::DeleteConfirm => "session_delete",
SessionPickerMode::TranscriptSearch => "session_search",
};
let control = if mode == SessionPickerMode::Filter {
"tui.select.confirm"
} else {
"tui.control.submit"
};
let cancel = if mode == SessionPickerMode::Filter {
"tui.select.cancel"
} else {
"tui.control.cancel"
};
let help = format!(
" {} open/save · {} cancel · {} search · {} new · {} rename · {} delete · {} inventory · {} migrate · {} backfill · {} cancel maintenance",
keymap.hint(scope, control),
keymap.hint(scope, cancel),
keymap.hint("session_picker", "tui.session.search"),
keymap.hint("session_picker", "tui.session.new"),
keymap.hint("session_picker", "tui.session.rename"),
keymap.hint("session_picker", "tui.session.delete"),
keymap.hint("session_search", "tui.search.index"),
keymap.hint("session_search", "tui.search.migrate"),
keymap.hint("session_search", "tui.search.backfill"),
keymap.hint("session_search", "tui.search.cancelMaintenance")
);
Line::from_spans(vec![
Span::styled("SESSIONS", Style::new().add_modifier(Modifier::BOLD)),
Span::raw(help),
])
}
fn format_import_warnings(
session: &bcode_session_models::SessionSummary,
warnings: &[bcode_ipc::SessionImportWarning],
) -> String {
let source = session
.import
.as_ref()
.map_or("external", |import| import.source_id.as_str());
let details = warnings
.iter()
.take(3)
.map(|warning| {
warning.count.map_or_else(
|| warning.message.clone(),
|count| format!("{} ({count})", warning.message),
)
})
.collect::<Vec<_>>()
.join("; ");
let suffix = if warnings.len() > 3 {
format!("; +{} more", warnings.len() - 3)
} else {
String::new()
};
format!(
"Imported [{source}] with {} warnings: {details}{suffix}; cancel dismisses.",
warnings.len()
)
}
#[cfg(test)]
mod tests {
use bmux_tui::buffer::Buffer;
use bmux_tui::frame::Frame;
use bmux_tui::geometry::{Point, Rect};
use super::{header_line, render_picker};
use crate::render::TuiTheme;
use crate::session_picker::{SessionPickerApp, SessionPickerMode};
use bcode_session_models::{SessionId, SessionSummary, SessionTitleSource};
fn buffer_text(buffer: &Buffer, area: Rect) -> String {
(area.y..area.y.saturating_add(area.height))
.flat_map(|y| {
(area.x..area.x.saturating_add(area.width)).filter_map(move |x| {
buffer
.get(bmux_tui::geometry::Point::new(x, y))
.map(|cell| cell.symbol.as_str())
})
})
.collect()
}
#[test]
fn search_help_is_textual_and_names_separate_maintenance_actions() {
let line = header_line(SessionPickerMode::TranscriptSearch);
let text = line
.spans
.iter()
.map(|span| span.content.as_str())
.collect::<String>();
for expected in [
"alt+i inventory",
"alt+g migrate",
"alt+b backfill",
"alt+x cancel",
] {
assert!(text.contains(expected), "missing {expected}: {text}");
}
}
#[test]
fn selected_session_uses_accent_on_raised_surface_across_themes() {
for theme_id in ["terminal-native", "bcode-dark", "bcode-light"] {
let theme = TuiTheme::for_theme_id(theme_id);
let session = SessionSummary {
id: SessionId::new(),
parent_id: None,
name: Some("Design review".to_owned()),
explicit_name: None,
derived_title: None,
automatic_title: None,
automatic_title_source_sequence: None,
title_source: SessionTitleSource::Explicit,
client_count: 0,
created_at_ms: 1,
updated_at_ms: 1,
working_directory: "/tmp/project".into(),
import: None,
execution: None,
location: None,
};
let mut app = SessionPickerApp::new(vec![session]);
let area = Rect::new(0, 0, 90, 15);
let mut buffer = Buffer::empty(area);
let mut frame = Frame::new(&mut buffer);
render_picker(
&mut app,
&mut bmux_tui::paint::PaintCx::new(&mut frame),
theme,
);
let text = buffer_text(frame.buffer(), area);
assert!(text.contains("Design review"), "{theme_id}");
assert!(text.contains("/tmp/project"), "{theme_id}");
let marker = frame
.buffer()
.get(Point::new(2, 6))
.expect("selection marker");
assert_eq!(marker.style.bg, theme.raised.bg, "{theme_id}");
assert_eq!(marker.style.fg, theme.focused.fg, "{theme_id}");
}
}
#[test]
fn scope_controls_have_visible_bounded_hits_at_tiny_and_unicode_widths() {
for width in [0, 1, 2, 12, 45, 90] {
let mut app = SessionPickerApp::new(Vec::new());
let area = Rect::new(0, 0, width, 12);
let mut buffer = Buffer::empty(area);
let mut frame = Frame::new(&mut buffer);
render_picker(
&mut app,
&mut bmux_tui::paint::PaintCx::new(&mut frame),
TuiTheme::for_theme_id("terminal-native"),
);
for hit in frame.hits().regions() {
assert!(hit.area.right() <= area.right());
assert!(hit.area.bottom() <= area.bottom());
}
if width == 90 {
let text = buffer_text(frame.buffer(), area);
assert!(text.contains("This directory"));
assert!(text.contains("All directories"));
assert!(
frame
.hits()
.regions()
.iter()
.any(|hit| hit.id.as_str() == "session.scope.local")
);
assert!(
frame
.hits()
.regions()
.iter()
.any(|hit| hit.id.as_str() == "session.scope.all")
);
}
}
}
#[test]
fn search_renderer_remains_bounded_on_narrow_terminal() {
let mut app = SessionPickerApp::new(Vec::new());
app.start_transcript_search();
app.set_status("query incomplete; compatibility blockers remain".to_owned());
let area = Rect::new(0, 0, 24, 8);
let mut buffer = Buffer::empty(area);
let mut frame = Frame::new(&mut buffer);
render_picker(
&mut app,
&mut bmux_tui::paint::PaintCx::new(&mut frame),
TuiTheme::for_theme_id("terminal-native"),
);
let text = buffer_text(frame.buffer(), area);
assert!(text.contains("Sessions"));
assert_eq!(frame.buffer().area(), area);
}
}