use super::*;
use crossterm;
use crossterm::{
cursor,
event::{Event, KeyCode, KeyEvent, KeyModifiers},
style::{Attribute, Color, ContentStyle, PrintStyledContent, StyledContent},
terminal, QueueableCommand,
};
use std::io::stdout;
const HELP: &str = r##"y - apply this suggestion
n - do not apply the suggested correction
q - quit; do not stage this hunk or any of the remaining ones
d - do not apply this suggestion and skip the rest of the file
g - select a suggestion to go to
j - leave this hunk undecided, see next undecided hunk
J - leave this hunk undecided, see next hunk
e - manually edit the current hunk
? - print help
"##;
pub struct ScopedRaw;
impl ScopedRaw {
fn new() -> Result<Self> {
crossterm::terminal::enable_raw_mode()?;
Ok(Self)
}
pub fn restore_terminal() -> Result<()> {
crossterm::terminal::disable_raw_mode()?;
stdout()
.queue(crossterm::cursor::Show)?
.flush()
.wrap_err_with(|| eyre!("Failed to restore terminal"))
}
}
impl Drop for ScopedRaw {
fn drop(&mut self) {
let _ = Self::restore_terminal();
}
}
#[derive(Debug, Clone, Copy)]
enum Direction {
Forward,
#[allow(unused)]
Backward,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum UserSelection {
Replacement(BandAid),
Skip,
Previous,
Help,
SkipFile,
Nop,
Abort,
Quit,
}
#[derive(Debug)]
struct State<'s, 't>
where
't: 's,
{
pub suggestion: &'s Suggestion<'t>,
pub custom_replacement: String,
pub cursor_offset: u16,
pub backticked_original: String,
pub pick_idx: usize,
pub n_items: usize,
}
impl<'s, 't> From<&'s Suggestion<'t>> for State<'s, 't> {
fn from(suggestion: &'s Suggestion<'t>) -> Self {
Self {
suggestion,
custom_replacement: String::new(),
cursor_offset: 0,
backticked_original: format!(
"`{}`",
sub_chars(suggestion.chunk.as_str(), suggestion.range.clone())
),
pick_idx: 1_usize + usize::from(!suggestion.replacements.is_empty()),
n_items: suggestion.replacements.len() + 2,
}
}
}
impl<'s, 't> State<'s, 't>
where
't: 's,
{
pub fn select_next(&mut self) {
self.pick_idx = (self.pick_idx + 1).rem_euclid(self.n_items);
}
pub fn select_previous(&mut self) {
self.pick_idx = (self.pick_idx + self.n_items - 1).rem_euclid(self.n_items);
}
pub fn select_custom(&mut self) {
self.pick_idx = 0;
}
pub fn is_custom_entry(&self) -> bool {
self.pick_idx == 0
}
pub fn is_ticked_entry(&self) -> bool {
self.pick_idx == 1
}
pub fn to_bandaid(&self) -> BandAid {
if self.is_ticked_entry() {
BandAid::from((self.backticked_original.clone(), &self.suggestion.span))
} else if self.is_custom_entry() {
BandAid::from((self.custom_replacement.clone(), &self.suggestion.span))
} else {
let replacement = self
.suggestion
.replacements
.get(self.pick_idx.saturating_sub(2)) .expect("User Pick index is never out of bounds. qed");
BandAid::from((replacement.to_owned(), &self.suggestion.span))
}
}
}
#[derive(Debug, Clone, Default)]
pub struct UserPicked {
pub bandaids: indexmap::IndexMap<ContentOrigin, Vec<BandAid>>,
}
impl UserPicked {
pub fn total_count(&self) -> usize {
self.bandaids
.iter()
.map(|(_origin, bandaids)| bandaids.len())
.sum()
}
pub fn is_empty(&self) -> bool {
!self
.bandaids
.iter()
.any(|(_origin, bandaids)| !bandaids.is_empty())
}
pub fn add_bandaid(&mut self, origin: &ContentOrigin, bandaid: BandAid) {
self.bandaids
.entry(origin.clone())
.or_insert_with(|| Vec::with_capacity(10))
.push(bandaid);
}
pub fn add_bandaids<I>(&mut self, origin: &ContentOrigin, fixes: I)
where
I: IntoIterator<Item = BandAid>,
{
let iter = fixes.into_iter();
self.bandaids
.entry(origin.clone())
.or_insert_with(|| Vec::with_capacity(iter.size_hint().0))
.extend(iter);
}
pub fn extend(&mut self, other: Self) {
self.bandaids.extend(other.bandaids);
}
fn enter_custom_replacement(
&self,
state: &mut State,
event: KeyEvent,
) -> Result<UserSelection> {
let KeyEvent {
code, modifiers, ..
} = event;
let length = state.custom_replacement.len() as u16;
match code {
KeyCode::Left => state.cursor_offset = state.cursor_offset.saturating_sub(1),
KeyCode::Right => state.cursor_offset = (state.cursor_offset + 1).min(length),
KeyCode::Up => {
state.cursor_offset = length;
state.select_next();
}
KeyCode::Down => {
state.cursor_offset = length;
state.select_previous();
}
KeyCode::Backspace => {
if state.cursor_offset > 0 {
state.cursor_offset -= 1;
state
.custom_replacement
.remove(state.cursor_offset as usize);
}
}
KeyCode::Enter => {
let bandaid = state.to_bandaid();
return Ok(UserSelection::Replacement(bandaid));
}
KeyCode::Esc => return Ok(UserSelection::Abort),
KeyCode::Char('c') if modifiers == KeyModifiers::CONTROL => {
return Ok(UserSelection::Abort);
}
KeyCode::Char(c) => {
state
.custom_replacement
.insert(state.cursor_offset as usize, c);
state.cursor_offset += 1;
}
_ => {}
}
Ok(UserSelection::Nop)
}
fn print_replacements_list(&self, state: &mut State) -> Result<()> {
let mut stdout = stdout();
let mut tick = ContentStyle::new();
tick.foreground_color = Some(Color::Green);
tick.attributes = Attribute::Bold.into();
let mut highlight = ContentStyle::new();
highlight.background_color = Some(Color::Black);
highlight.foreground_color = Some(Color::Green);
highlight.attributes = Attribute::Bold.into();
let mut others = ContentStyle::new();
others.background_color = Some(Color::Black);
others.foreground_color = Some(Color::Blue);
let mut custom = ContentStyle::new();
custom.background_color = Some(Color::Black);
custom.foreground_color = Some(Color::Yellow);
stdout.queue(cursor::SavePosition)?;
let _ = stdout.queue(cursor::MoveDown(1))?;
let active_idx = state.pick_idx;
let custom_content = if state.custom_replacement.is_empty() {
"..."
} else {
state.custom_replacement.as_str()
};
std::iter::once((&custom, custom_content))
.chain(std::iter::once((
&others,
state.backticked_original.as_str(),
)))
.chain(
state
.suggestion
.replacements
.iter()
.map(|s| (&others, s.as_str())),
)
.enumerate()
.map(|(idx, (style, content))| {
(idx, PrintStyledContent(StyledContent::new(*style, content)))
})
.try_fold(&mut stdout, |cmd, (idx, mut item)| {
let cmd = cmd
.queue(cursor::MoveUp(1))?
.queue(terminal::Clear(terminal::ClearType::CurrentLine))?;
if idx == active_idx {
*item.0.style_mut() = highlight;
if idx == 0 {
cmd.queue(crossterm::cursor::Show)?;
} else {
cmd.queue(crossterm::cursor::Hide)?;
}
cmd.queue(cursor::MoveToColumn(2))?
.queue(PrintStyledContent(StyledContent::new(tick, '»')))?
.queue(cursor::MoveToColumn(4))?
} else {
cmd.queue(cursor::MoveToColumn(4))?
}
.queue(item)
})?;
stdout.queue(cursor::RestorePosition)?.flush()?;
Ok(())
}
fn user_input(
&self,
state: &mut State,
running_idx: usize,
total: usize,
) -> Result<UserSelection> {
let skip = {
let _guard = ScopedRaw::new();
let mut boring = ContentStyle::new();
boring.foreground_color = Some(Color::Blue);
boring.attributes = Attribute::Bold.into();
let question = format!(
"({nth}/{of_n}) Apply this suggestion [y,n,q,a,d,j,e,?]?",
nth = running_idx + 1,
of_n = total
);
#[allow(clippy::items_after_statements)]
const ERASE: u16 = 4;
const QUESTION: u16 = 3;
let extra_rows_to_flush =
(state.n_items.saturating_sub((ERASE - QUESTION) as usize)) as u16;
stdout()
.queue(cursor::Hide)?
.queue(cursor::MoveUp(ERASE))? .queue(terminal::Clear(terminal::ClearType::FromCursorDown))?
.queue(cursor::MoveDown(1))? .queue(PrintStyledContent(StyledContent::new(boring, question)))?
.queue(terminal::ScrollUp(extra_rows_to_flush))?
.queue(cursor::MoveToColumn(0))?
.queue(cursor::MoveDown(extra_rows_to_flush))?;
ERASE - QUESTION
};
loop {
let mut _guard = ScopedRaw::new();
self.print_replacements_list(state)?;
if state.is_custom_entry() {
stdout().queue(cursor::SavePosition)?;
stdout()
.queue(cursor::Show)?
.queue(cursor::MoveToPreviousLine(1 - skip))?
.queue(cursor::MoveToColumn(4 + state.cursor_offset))?;
stdout().flush()?;
}
let event = match crossterm::event::read()
.wrap_err_with(|| eyre!("Something unexpected happened on the CLI"))?
{
Event::Key(event) => event,
Event::Resize(..) => {
drop(_guard);
continue;
}
sth => {
log::trace!("read() something other than a key: {sth:?}");
break;
}
};
if state.is_custom_entry() {
drop(_guard);
log::info!("Custom entry mode");
_guard = ScopedRaw::new();
let pick = self.enter_custom_replacement(state, event)?;
stdout()
.queue(cursor::Hide)?
.queue(cursor::RestorePosition)?;
match pick {
UserSelection::Nop => continue,
other => return Ok(other),
}
}
drop(_guard);
log::trace!("registered event: {event:?}");
let KeyEvent {
code, modifiers, ..
} = event;
match code {
KeyCode::Up => state.select_next(),
KeyCode::Down => state.select_previous(),
KeyCode::Enter | KeyCode::Char('y') => {
let bandaid = state.to_bandaid();
return Ok(UserSelection::Replacement(bandaid));
}
KeyCode::Char('n') => return Ok(UserSelection::Skip),
KeyCode::Char('j') => return Ok(UserSelection::Previous),
KeyCode::Char('q') | KeyCode::Esc => return Ok(UserSelection::Quit),
KeyCode::Char('c') if modifiers == KeyModifiers::CONTROL => {
return Ok(UserSelection::Abort)
}
KeyCode::Char('d') => return Ok(UserSelection::SkipFile),
KeyCode::Char('e') => {
state.select_custom();
}
KeyCode::Char('?') => return Ok(UserSelection::Help),
x => {
log::trace!("Unexpected input {x:?}");
}
}
}
unreachable!("Unexpected return when dealing with user input")
}
pub(super) fn select_interactive(
origin: ContentOrigin,
suggestions: Vec<Suggestion<'_>>,
) -> Result<(Self, UserSelection)> {
let count = suggestions.len();
let mut picked = UserPicked::default();
let mut suggestions_it = suggestions.iter().enumerate();
let start = suggestions_it.clone();
let direction = Direction::Forward;
'outer: loop {
let opt_next = match direction {
Direction::Forward => suggestions_it.next(),
Direction::Backward => suggestions_it.next_back(),
};
log::trace!("next() ---> {opt_next:?}");
let (idx, suggestion) = match opt_next {
Some(x) => x,
None => match direction {
Direction::Forward => {
log::trace!("completed file, continue to next");
break; }
Direction::Backward => {
log::trace!("went back, now back at the beginning");
suggestions_it = start.clone();
continue;
} },
};
if suggestion.replacements.is_empty() {
log::trace!("BUG: Suggestion did not contain a replacement, skip");
continue;
}
println!("{suggestion}");
let mut state = State::from(suggestion);
'inner: loop {
match picked.user_input(&mut state, idx, count)? {
usel @ (UserSelection::Abort | UserSelection::Quit) => {
let _ = ScopedRaw::restore_terminal();
return Ok((picked, usel));
}
UserSelection::SkipFile => break 'outer,
UserSelection::Previous => {
log::warn!("Requires a iterator which works bidrectionally");
continue 'inner;
}
UserSelection::Help => {
println!("{HELP}");
continue 'inner;
}
UserSelection::Replacement(bandaid) => {
picked.add_bandaid(&origin, bandaid);
}
UserSelection::Nop | UserSelection::Skip => {}
};
break 'inner;
}
}
Ok((picked, UserSelection::Nop))
}
}