use super::app::{AppState, CurrentPane, Model, SelectingCommandState};
use crate::model::command;
use ratatui::{
Frame,
layout::{Constraint, Direction, Flex, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
};
pub fn ui(f: &mut Frame, model: &mut Model) {
if let AppState::SelectingCommand(model) = &mut model.app_state {
let main_and_key_bindings = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(1)])
.split(f.area());
render_hint_block(model, f, main_and_key_bindings[1]);
let main = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(3)])
.split(main_and_key_bindings[0]);
let input_and_notification = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(main[1]);
render_input_block(model, f, input_and_notification[0]);
let notification_and_current_version = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(100), Constraint::Length(9)])
.split(input_and_notification[1]);
render_notification_block(model, f, notification_and_current_version[0]);
render_current_version_block(f, notification_and_current_version[1]);
let commands = if f.area().height < HEIGHT_THRESHOLD_TO_HIDE_PREVIEW_WINDOW {
let preview_and_commands = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(100)])
.split(main[0]);
preview_and_commands[0]
} else {
let preview_and_commands = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(main[0]);
render_preview_block(model, f, preview_and_commands[0]);
preview_and_commands[1]
};
let commands_and_history = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(commands);
render_commands_block(model, f, commands_and_history[0]);
render_history_block(model, f, commands_and_history[1]);
render_additional_arguments_popup(model, f);
}
}
const HEIGHT_THRESHOLD_TO_HIDE_PREVIEW_WINDOW: u16 = 20;
const FG_COLOR_SELECTED: ratatui::style::Color = Color::Rgb(161, 220, 156);
const FG_COLOR_NOT_SELECTED: ratatui::style::Color = Color::DarkGray;
const BORDER_STYLE_SELECTED: ratatui::widgets::block::BorderType = ratatui::widgets::BorderType::Thick;
const BORDER_STYLE_NOT_SELECTED: ratatui::widgets::block::BorderType = ratatui::widgets::BorderType::Plain;
const TITLE_STYLE: ratatui::style::Style = Style::new().add_modifier(Modifier::BOLD);
const COMMAND_ROW_BG_COLOR: ratatui::style::Color = Color::Rgb(94, 120, 200);
fn color_and_border_style_for_selectable(
is_selected: bool,
is_additional_arguments_popup_opened: bool,
) -> (Color, ratatui::widgets::block::BorderType) {
if is_selected && !is_additional_arguments_popup_opened {
(FG_COLOR_SELECTED, BORDER_STYLE_SELECTED)
} else {
(FG_COLOR_NOT_SELECTED, BORDER_STYLE_NOT_SELECTED)
}
}
fn render_preview_block(model: &SelectingCommandState, f: &mut Frame, chunk: ratatui::layout::Rect) {
let narrow_down_commands = model.narrow_down_commands();
let selecting_command = narrow_down_commands.get(model.commands_list_state.selected().unwrap_or(0));
let lines = match selecting_command {
Some(command) => {
let command_row_index = command.line_number as usize - 1;
let row_count = chunk.rows().count() - 2; let (start_index, end_index) = determine_rendering_position(row_count, command_row_index);
model
.preview_cache
.styled_lines(&command.file_path, start_index, end_index)
.into_iter()
.enumerate()
.map(|(index, styled_line)| {
let row_index = start_index + index;
let mut spans = vec![Span::styled(format!("{:5} ", row_index + 1), Style::default())];
spans.extend(styled_line.into_iter().map(|(style, content)| {
let style = if row_index == command_row_index {
style.bg(COMMAND_ROW_BG_COLOR)
} else {
style
};
Span::styled(content, style)
}));
Line::from(spans)
})
.collect()
}
None => vec![],
};
let (fg_color_, border_style) = color_and_border_style_for_selectable(
model.current_pane.is_main(),
model.is_additional_arguments_popup_opened(),
);
let block = Block::default()
.borders(Borders::ALL)
.border_type(border_style)
.border_style(Style::default().fg(fg_color_))
.title(" ✨ Preview ")
.title_style(TITLE_STYLE);
let preview_widget = Paragraph::new(lines).wrap(Wrap { trim: false }).block(block);
f.render_widget(preview_widget, chunk);
}
fn determine_rendering_position(row_count: usize, command_row_index: usize) -> (usize, usize) {
let middle_row_index = if row_count.is_multiple_of(2) {
row_count / 2 - 1
} else {
row_count.div_ceil(2) - 1
};
if command_row_index < middle_row_index {
(0, row_count - 1)
} else {
let start_index = command_row_index - middle_row_index;
let end_index = start_index + row_count - 1;
(start_index, end_index)
}
}
fn render_commands_block(model: &mut SelectingCommandState, f: &mut Frame, chunk: ratatui::layout::Rect) {
f.render_stateful_widget(
commands_block(
" 📢 Commands ",
model.narrow_down_commands().into_iter().map(|c| c.into()).collect(),
model.current_pane.is_main(),
model.is_additional_arguments_popup_opened(),
),
chunk,
&mut model.commands_list_state,
);
}
fn render_input_block(model: &mut SelectingCommandState, f: &mut Frame, chunk: ratatui::layout::Rect) {
let (fg_color, border_style) = color_and_border_style_for_selectable(
model.current_pane.is_main(),
model.is_additional_arguments_popup_opened(),
);
let block = Block::default()
.title(" 🔍 Search ")
.title_style(TITLE_STYLE)
.borders(Borders::ALL)
.border_type(border_style)
.border_style(Style::default().fg(fg_color))
.style(Style::default())
.padding(ratatui::widgets::Padding::new(2, 2, 0, 0));
model.search_text_area.0.set_block(block);
model
.search_text_area
.0
.set_placeholder_text("Type text to search command");
f.render_widget(&model.search_text_area.0, chunk);
}
fn render_notification_block(model: &mut SelectingCommandState, f: &mut Frame, chunk: ratatui::layout::Rect) {
let text = {
if let Some(s) = &model.copy_command_state {
match s {
Ok(c) => format!("📋 Command copied to clipboard: {}", c),
Err(e) => format!("⚠️ Failed to copy command to clipboard: {}", e),
}
} else {
match &model.latest_version {
Some(has_update) => {
if format!("v{}", env!("CARGO_PKG_VERSION")) != *has_update {
format!(
"📦️ A new release is available! v{} → {}.",
env!("CARGO_PKG_VERSION"),
has_update.as_str()
)
} else {
String::new()
}
}
None => String::new(),
}
}
};
let notification = Span::styled(text, Style::default());
let block = Block::default()
.padding(ratatui::widgets::Padding::new(1, 0, 1, 1))
.style(Style::new().add_modifier(Modifier::BOLD).fg(Color::Yellow));
let key_notes_footer = Paragraph::new(notification).wrap(Wrap { trim: true }).block(block);
f.render_widget(key_notes_footer, chunk);
}
fn render_current_version_block(f: &mut Frame, chunk: ratatui::layout::Rect) {
let text = format!("v{}", env!("CARGO_PKG_VERSION"));
let notification = Span::styled(text, Style::default());
let block = Block::default().padding(ratatui::widgets::Padding::new(0, 1, 2, 0));
let key_notes_footer = Paragraph::new(notification)
.block(block)
.right_aligned()
.wrap(Wrap { trim: true });
f.render_widget(key_notes_footer, chunk);
}
fn render_history_block(model: &mut SelectingCommandState, f: &mut Frame, chunk: ratatui::layout::Rect) {
f.render_stateful_widget(
commands_block(
" 📚 History ",
model.get_history().into_iter().map(|c| c.into()).collect(),
model.current_pane.is_history(),
model.is_additional_arguments_popup_opened(),
),
chunk,
&mut model.history_list_state,
);
}
fn popup_area(area: Rect, x: u16, y: u16) -> Rect {
let vertical = Layout::vertical([Constraint::Length(y)]).flex(Flex::Center);
let horizontal = Layout::horizontal([Constraint::Percentage(x)]).flex(Flex::Center);
let [area] = vertical.areas(area);
let [area] = horizontal.areas(area);
area
}
fn render_additional_arguments_popup(model: &mut SelectingCommandState, f: &mut Frame) {
if model.additional_arguments_popup_state.is_none() {
return;
}
let command = model.get_selected_command().unwrap();
let block = Block::default()
.borders(Borders::ALL)
.border_type(BORDER_STYLE_SELECTED)
.border_style(Style::default().fg(FG_COLOR_SELECTED))
.title(format!(" 👋 Pass additional arguments to `{}`", command));
let area = popup_area(f.area(), 60, 3);
f.render_widget(Clear, area);
let mut additional_arguments_popup_state = model.additional_arguments_popup_state.clone().unwrap();
additional_arguments_popup_state.arguments_text_area.0.set_block(block);
f.render_widget(&additional_arguments_popup_state.arguments_text_area.0, area);
}
fn render_hint_block(model: &mut SelectingCommandState, f: &mut Frame, chunk: ratatui::layout::Rect) {
let hint_text = if model.is_additional_arguments_popup_opened() {
"Execute the selected command: <enter> | Passing additional arguments: (type any character) | Close the popup window: <esc>"
} else {
match model.current_pane {
CurrentPane::Main => {
"Execute the selected command: <enter> | Select command: ↑(<c-p>)/↓(<c-n>) | Narrow down command: (type any character) | Quit: <c-c>/<esc> | Move to next tab: <tab> | Copy command to clipboard: <c-y> | Pass additional arguments: <c-o>"
}
CurrentPane::History => {
"Execute the selected command: <enter> | Select command: ↑(<c-p>)/↓(<c-n>) | Quit: <c-c>/q/<esc> | Move to next tab: <tab> | Copy command to clipboard: <c-y> | Pass additional arguments: <c-o>"
}
}
};
let hint = Span::styled(hint_text, Style::default().fg(FG_COLOR_SELECTED));
let block = Block::default().padding(ratatui::widgets::Padding::new(2, 2, 0, 0));
let key_notes_footer = Paragraph::new(hint).wrap(Wrap { trim: true }).block(block);
f.render_widget(key_notes_footer, chunk);
}
fn commands_block(
title: &str,
narrowed_down_commands: Vec<command::CommandForExec>,
is_current: bool,
is_additional_arguments_popup_opened: bool,
) -> List<'_> {
let (fg_color, border_style) =
color_and_border_style_for_selectable(is_current, is_additional_arguments_popup_opened);
let list: Vec<ListItem> = narrowed_down_commands
.into_iter()
.map(|command| ListItem::new(command.to_string()).style(Style::default()))
.collect();
List::new(list)
.style(Style::default())
.block(
Block::default()
.title(title)
.title_style(TITLE_STYLE)
.borders(Borders::ALL)
.border_type(border_style)
.border_style(Style::default().fg(fg_color))
.style(Style::default())
.padding(ratatui::widgets::Padding::new(2, 0, 0, 0)),
)
.highlight_style(Style::default().fg(FG_COLOR_SELECTED))
.highlight_symbol("> ")
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_determine_rendering_position() {
let (start, end) = determine_rendering_position(5, 4);
assert_eq!(start, 2);
assert_eq!(end, 6);
let (start, end) = determine_rendering_position(6, 4);
assert_eq!(start, 2);
assert_eq!(end, 7);
let (start, end) = determine_rendering_position(10, 1);
assert_eq!(start, 0);
assert_eq!(end, 9);
}
}