use crossterm::event::{Event, KeyCode, KeyEvent, MouseButton, MouseEventKind};
use ratatui::{
prelude::*,
widgets::{Block, Borders, Paragraph},
};
use crate::model::changeset::ChangeType;
use crate::package_manager::Project;
use crate::tui::widgets::{self, KeyResult, button_style};
use super::{BackState, HandleResult, Screen, SelectProjectsState, enter_message};
const QUESTION_HEIGHT: u16 = 3;
fn new_screen(
selected: &[bool],
levels: &[ChangeType],
cursor: usize,
error: bool,
changed_count: usize,
) -> Screen {
Screen::SelectProjects(SelectProjectsState {
selected: selected.to_vec(),
levels: levels.to_vec(),
cursor,
error,
changed_count,
})
}
fn move_project_cursor(
selected: &[bool],
levels: &[ChangeType],
cursor: usize,
changed_count: usize,
up: bool,
) -> HandleResult {
let len = selected.len();
let new_cursor = if up {
if cursor == 0 { len - 1 } else { cursor - 1 }
} else if cursor + 1 >= len {
0
} else {
cursor + 1
};
KeyResult::Continue(new_screen(
selected,
levels,
new_cursor,
false,
changed_count,
))
}
fn advance_to_enter_message(
selected: &[bool],
levels: &[ChangeType],
cursor: usize,
changed_count: usize,
projects: &[Project],
) -> HandleResult {
if selected.iter().any(|&s| s) {
let selected_projects: Vec<(Project, ChangeType)> = selected
.iter()
.enumerate()
.filter(|(_, s)| **s)
.map(|(i, _)| (projects[i].clone(), levels[i]))
.collect();
let back = BackState::MultiPackage(SelectProjectsState {
selected: selected.to_vec(),
levels: levels.to_vec(),
cursor,
error: false,
changed_count,
});
let textarea = enter_message::initial_textarea();
KeyResult::Continue(Screen::EnterMessage {
textarea,
projects: selected_projects,
back,
})
} else {
KeyResult::Continue(new_screen(selected, levels, cursor, true, changed_count))
}
}
fn toggle_group(selected: &[bool], start: usize, end: usize) -> Vec<bool> {
let all_on = selected[start..end].iter().all(|&s| s);
let mut new_selected = selected.to_vec();
for s in new_selected[start..end].iter_mut() {
*s = !all_on;
}
new_selected
}
fn cycle_single_level(
selected: &[bool],
levels: &[ChangeType],
cursor: usize,
changed_count: usize,
forward: bool,
) -> HandleResult {
if selected[cursor] {
let mut new_levels = levels.to_vec();
new_levels[cursor] = if forward {
new_levels[cursor].next()
} else {
new_levels[cursor].prev()
};
KeyResult::Continue(new_screen(
selected,
&new_levels,
cursor,
false,
changed_count,
))
} else {
KeyResult::Continue(new_screen(selected, levels, cursor, false, changed_count))
}
}
fn cycle_all_levels(
selected: &[bool],
levels: &[ChangeType],
cursor: usize,
changed_count: usize,
forward: bool,
) -> HandleResult {
if let Some(first) = selected.iter().position(|&s| s) {
let new_level = if forward {
levels[first].next()
} else {
levels[first].prev()
};
let mut new_levels = levels.to_vec();
selected
.iter()
.enumerate()
.filter(|(_, s)| **s)
.for_each(|(i, _)| new_levels[i] = new_level);
KeyResult::Continue(new_screen(
selected,
&new_levels,
cursor,
false,
changed_count,
))
} else {
KeyResult::Continue(new_screen(selected, levels, cursor, false, changed_count))
}
}
fn handle_selection_key(
selected: &[bool],
levels: &[ChangeType],
cursor: usize,
changed_count: usize,
key: KeyCode,
len: usize,
) -> HandleResult {
let nc =
|sel: &[bool]| KeyResult::Continue(new_screen(sel, levels, cursor, false, changed_count));
match key {
KeyCode::Char(' ') => {
let mut s = selected.to_vec();
s[cursor] = !s[cursor];
nc(&s)
}
KeyCode::Char('a') => nc(&vec![!selected.iter().all(|&s| s); len]),
KeyCode::Char('c') => {
let end = changed_count.min(len);
if end == 0 {
nc(selected)
} else {
nc(&toggle_group(selected, 0, end))
}
}
KeyCode::Char('u') => {
let start = changed_count.min(len);
if start >= len {
nc(selected)
} else {
nc(&toggle_group(selected, start, len))
}
}
_ => nc(selected),
}
}
fn handle_key_inner(
selected: &[bool],
levels: &[ChangeType],
cursor: usize,
changed_count: usize,
key: KeyCode,
projects: &[Project],
) -> HandleResult {
let len = selected.len();
if len == 0 {
return match key {
KeyCode::Esc | KeyCode::Char('q') => KeyResult::Cancelled,
_ => KeyResult::Continue(new_screen(&[], &[], 0, false, changed_count)),
};
}
match key {
KeyCode::Up | KeyCode::Char('k') => {
move_project_cursor(selected, levels, cursor, changed_count, true)
}
KeyCode::Down | KeyCode::Char('j') => {
move_project_cursor(selected, levels, cursor, changed_count, false)
}
KeyCode::Left | KeyCode::Char('h') => {
cycle_single_level(selected, levels, cursor, changed_count, false)
}
KeyCode::Right | KeyCode::Char('l') => {
cycle_single_level(selected, levels, cursor, changed_count, true)
}
KeyCode::Char(',') => cycle_all_levels(selected, levels, cursor, changed_count, false),
KeyCode::Char('.') => cycle_all_levels(selected, levels, cursor, changed_count, true),
KeyCode::Char(' ' | 'a' | 'c' | 'u') => {
handle_selection_key(selected, levels, cursor, changed_count, key, len)
}
KeyCode::Enter => {
advance_to_enter_message(selected, levels, cursor, changed_count, projects)
}
KeyCode::Esc | KeyCode::Char('q') => KeyResult::Cancelled,
_ => KeyResult::Continue(new_screen(selected, levels, cursor, false, changed_count)),
}
}
fn row_to_project_index(inner_row: u16, changed_count: usize, total: usize) -> Option<usize> {
let row = inner_row as usize;
if row == 0 {
return None; }
if row <= changed_count {
return Some(row - 1);
}
if row == changed_count + 1 {
return None; }
let project_idx = changed_count + (row - changed_count - 2);
if project_idx < total {
Some(project_idx)
} else {
None
}
}
fn click_level_at(col: u16, inner_x_start: u16, name_col_width: usize) -> Option<ChangeType> {
let level_start = inner_x_start + 9 + name_col_width as u16;
if col < level_start {
return None;
}
match (col - level_start) / 8 {
0 => Some(ChangeType::Major),
1 => Some(ChangeType::Minor),
2 => Some(ChangeType::Patch),
_ => None,
}
}
fn project_block_inner_bounds(content_area: Rect) -> (u16, u16, u16, u16) {
let help = crate::t!("select-projects-help");
let help_h = widgets::paragraph_height(&help, content_area.width, 0);
let chunks = widgets::wizard_layout(
content_area,
&[
Constraint::Length(QUESTION_HEIGHT),
Constraint::Min(5),
Constraint::Length(help_h),
],
);
let b = chunks[1];
(
b.y + 1,
b.y + b.height.saturating_sub(1),
b.x + 1,
b.x + b.width.saturating_sub(1),
)
}
fn handle_mouse_select_projects(
state: &SelectProjectsState,
col: u16,
row: u16,
content_area: Rect,
name_col_width: usize,
) -> HandleResult {
let (inner_y_start, inner_y_end, inner_x_start, inner_x_end) =
project_block_inner_bounds(content_area);
let no_change = || {
KeyResult::Continue(new_screen(
&state.selected,
&state.levels,
state.cursor,
false,
state.changed_count,
))
};
if row < inner_y_start || row >= inner_y_end || col < inner_x_start || col >= inner_x_end {
return no_change();
}
let inner_row = row - inner_y_start;
let total = state.selected.len();
match row_to_project_index(inner_row, state.changed_count, total) {
Some(project_idx) => {
if state.selected[project_idx]
&& let Some(lvl) = click_level_at(col, inner_x_start, name_col_width)
{
let mut new_levels = state.levels.to_vec();
new_levels[project_idx] = lvl;
return KeyResult::Continue(new_screen(
&state.selected,
&new_levels,
project_idx,
false,
state.changed_count,
));
}
let mut new_selected = state.selected.to_vec();
new_selected[project_idx] = !new_selected[project_idx];
KeyResult::Continue(new_screen(
&new_selected,
&state.levels,
project_idx,
false,
state.changed_count,
))
}
None => no_change(),
}
}
pub(super) fn handle_event_select_projects(
state: SelectProjectsState,
event: Event,
content_area: Rect,
projects: &[Project],
) -> HandleResult {
let SelectProjectsState {
selected,
levels,
cursor,
changed_count,
..
} = state;
match event {
Event::Key(KeyEvent { code, .. }) => {
handle_key_inner(&selected, &levels, cursor, changed_count, code, projects)
}
Event::Mouse(me) if matches!(me.kind, MouseEventKind::Down(MouseButton::Left)) => {
let name_col_width = projects.iter().map(|p| p.name().len()).max().unwrap_or(0);
handle_mouse_select_projects(
&SelectProjectsState {
selected,
levels,
cursor,
error: false,
changed_count,
},
me.column,
me.row,
content_area,
name_col_width,
)
}
_ => KeyResult::Continue(new_screen(&selected, &levels, cursor, false, changed_count)),
}
}
fn level_indicator(current: ChangeType) -> Vec<Span<'static>> {
[ChangeType::Major, ChangeType::Minor, ChangeType::Patch]
.into_iter()
.flat_map(|l| {
[
Span::styled(format!(" {l} "), button_style(l == current)),
Span::raw(" "),
]
})
.collect()
}
fn project_line(
name: &str,
name_col_width: usize,
level: ChangeType,
is_selected: bool,
is_cursor: bool,
) -> Line<'static> {
let checkbox = if is_selected { "[x]" } else { "[ ]" };
let style = if is_cursor {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if is_selected {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Gray)
};
let padded = format!("{:<width$}", name, width = name_col_width);
if is_selected {
let mut spans = vec![Span::styled(format!(" {checkbox} {padded} "), style)];
spans.extend(level_indicator(level));
Line::from(spans)
} else {
Line::from(Span::styled(format!(" {checkbox} {padded}"), style))
}
}
fn group_header(label: &'static str, count: usize) -> Line<'static> {
let header_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
if count == 0 {
Line::from(vec![
Span::styled(format!(" {label} "), header_style),
Span::styled("(none)", Style::default().add_modifier(Modifier::DIM)),
])
} else {
Line::from(Span::styled(format!(" {label} ({count})"), header_style))
}
}
fn build_project_lines(
project_names: &[&str],
selected: &[bool],
levels: &[ChangeType],
cursor: usize,
changed_count: usize,
) -> Vec<Line<'static>> {
let total = project_names.len();
let effective_changed = changed_count.min(total);
let unchanged_count = total - effective_changed;
let name_col_width = project_names.iter().map(|n| n.len()).max().unwrap_or(0);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(group_header("Changed", effective_changed));
for i in 0..effective_changed {
lines.push(project_line(
project_names[i],
name_col_width,
levels[i],
selected[i],
i == cursor,
));
}
lines.push(group_header("Unchanged", unchanged_count));
for i in effective_changed..total {
lines.push(project_line(
project_names[i],
name_col_width,
levels[i],
selected[i],
i == cursor,
));
}
lines
}
pub(super) fn render_select_projects(
frame: &mut Frame,
area: Rect,
project_names: &[&str],
state: &SelectProjectsState,
) {
let question_text = if state.error {
crate::t!("select-projects-error")
} else {
crate::t!("select-projects-question")
};
let question_color = if state.error {
Color::Red
} else {
Color::Yellow
};
let help = crate::t!("select-projects-help");
let help_h = widgets::paragraph_height(&help, area.width, 0);
let chunks = widgets::wizard_layout(
area,
&[
Constraint::Length(QUESTION_HEIGHT),
Constraint::Min(5),
Constraint::Length(help_h),
],
);
widgets::render_question(frame, chunks[0], &question_text, question_color);
let lines = build_project_lines(
project_names,
&state.selected,
&state.levels,
state.cursor,
state.changed_count,
);
let para = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title("Select Projects"),
);
frame.render_widget(para, chunks[1]);
widgets::render_help(frame, chunks[2], &help);
}
#[cfg(test)]
mod tests;