use crate::{
code_utils::write_source,
file_dialog::{DialogMode, FileDialog, Status},
key,
stdin::edit_history,
KeyCombination, ThagError, ThagResult,
};
use crossterm::event::{
self, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event::{self, Paste},
KeyEvent, KeyEventKind,
};
use mockall::automock;
use ratatui::crossterm::terminal::{
disable_raw_mode, enable_raw_mode, is_raw_mode_enabled, EnterAlternateScreen,
LeaveAlternateScreen,
};
use ratatui::layout::{Constraint, Direction, Layout, Margin};
use ratatui::prelude::{CrosstermBackend, Rect};
pub use ratatui::style::Style as RataStyle;
use ratatui::style::{Color, Modifier, Styled, Stylize};
use ratatui::text::Line;
use ratatui::widgets::{block::Block, Borders, Clear, Paragraph};
use ratatui::{CompletedFrame, Frame, Terminal};
use regex::Regex;
use scopeguard::{guard, ScopeGuard};
use serde::{Deserialize, Serialize};
use std::{
self,
collections::VecDeque,
convert::Into,
env::var,
fmt::{Debug, Display, Write as _},
fs::{self, OpenOptions},
io::Write,
path::PathBuf,
time::Duration,
};
use thag_common::{debug_log, re};
use thag_styling::{Role, ThemedStyle};
use thag_profiler::profiled;
use tui_textarea::{CursorMove, Input, TextArea};
pub const TITLE_TOP: &str = "Key bindings - subject to your terminal settings";
pub const TITLE_BOTTOM: &str = "Ctrl+l to hide";
pub type BackEnd<'a> = CrosstermBackend<std::io::StdoutLock<'a>>;
pub type Term<'a> = Terminal<BackEnd<'a>>;
pub type ResetTermClosure<'a> = Box<dyn FnOnce(Term<'a>)>;
pub type TermScopeGuard<'a> = ScopeGuard<Term<'a>, ResetTermClosure<'a>>;
#[automock]
pub trait EventReader {
fn read_event(&self) -> ThagResult<Event>;
fn poll(&self, timeout: Duration) -> ThagResult<bool>;
}
#[derive(Debug)]
pub struct CrosstermEventReader;
impl EventReader for CrosstermEventReader {
#[profiled]
fn read_event(&self) -> ThagResult<Event> {
crossterm::event::read().map_err(Into::<ThagError>::into)
}
#[profiled]
fn poll(&self, timeout: Duration) -> ThagResult<bool> {
crossterm::event::poll(timeout).map_err(Into::<ThagError>::into)
}
}
pub struct ManagedTerminal<'a> {
terminal: TermScopeGuard<'a>,
}
impl ManagedTerminal<'_> {
#[profiled]
pub fn draw<F>(&mut self, f: F) -> std::io::Result<CompletedFrame<'_>>
where
F: FnOnce(&mut Frame<'_>),
{
self.terminal.draw(f)
}
}
#[profiled]
pub fn resolve_term<'a>() -> ThagResult<Option<ManagedTerminal<'a>>> {
if var("TEST_ENV").is_ok() {
return Ok(None);
}
let mut stdout = std::io::stdout().lock();
enable_raw_mode()?;
ratatui::crossterm::execute!(
stdout,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste
)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(Some(ManagedTerminal {
terminal: guard(
terminal,
Box::new(|term| {
reset_term(term).expect("Error resetting terminal");
}),
),
}))
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Entry {
pub index: usize, pub lines: Vec<String>, }
impl Display for Entry {
#[profiled]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}: {}", self.index, self.lines.join("\n"))
}
}
impl Entry {
#[profiled]
pub fn new(index: usize, content: &str) -> Self {
Self {
index,
lines: content.lines().map(String::from).collect(),
}
}
#[must_use]
#[profiled]
pub fn contents(&self) -> String {
self.lines.join("\n")
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct History {
pub current_index: Option<usize>,
pub entries: VecDeque<Entry>, }
impl History {
#[must_use]
#[profiled]
pub fn new() -> Self {
Self {
current_index: None,
entries: VecDeque::with_capacity(20),
}
}
#[must_use]
#[profiled]
pub fn load_from_file(path: &PathBuf) -> Self {
let mut history = fs::read_to_string(path).map_or_else(
|_| Self::default(),
|data| serde_json::from_str(&data).unwrap_or_else(|_| Self::new()),
);
debug_log!("Loaded history={history:?}");
history.entries.retain(|e| !e.contents().trim().is_empty());
history.reassign_indices();
if history.entries.is_empty() {
history.current_index = None;
} else {
history.current_index = Some(history.entries.len() - 1);
}
debug_log!("history={history:?}");
debug_log!(
"load_from_file({path:?}); current index={:?}",
history.current_index
);
history
}
#[allow(clippy::unnecessary_map_or)]
#[must_use]
#[profiled]
pub fn at_start(&self) -> bool {
debug_log!("at_start ...");
self.current_index
.map_or(true, |current_index| current_index == 0)
}
#[allow(clippy::unnecessary_map_or)]
#[must_use]
#[profiled]
pub fn at_end(&self) -> bool {
debug_log!("at_end ...");
self.current_index.map_or(true, |current_index| {
current_index == self.entries.len() - 1
})
}
#[profiled]
pub fn add_entry(&mut self, text: &str) {
let new_index = self.entries.len(); let new_entry = Entry::new(new_index, text);
self.entries
.retain(|f| f.contents().trim() != new_entry.contents().trim());
self.entries.push_back(new_entry);
self.current_index = Some(self.entries.len() - 1);
debug_log!("add_entry({text}); current index={:?}", self.current_index);
debug_log!("history={self:?}");
}
#[profiled]
pub fn update_entry(&mut self, index: usize, text: &str) {
debug_log!("update_entry for index {index}...");
let current_index = self.current_index;
if let Some(entry) = self.get_mut(index) {
entry.lines = text.lines().map(String::from).collect::<Vec<String>>();
debug_log!("... update_entry({entry:?}); current index={current_index:?}");
} else {
self.add_entry(text);
}
}
#[profiled]
pub fn delete_entry(&mut self, index: usize) {
self.entries.retain(|entry| entry.index != index);
self.reassign_indices();
if self.entries.is_empty() {
self.current_index = None;
} else {
self.current_index = Some(self.entries.len() - 1);
}
}
#[profiled]
pub fn save_to_file(&mut self, path: &PathBuf) -> ThagResult<()> {
self.reassign_indices();
if let Ok(data) = serde_json::to_string(&self) {
debug_log!("About to write data=({data}");
if let Ok(metadata) = std::fs::metadata(path) {
debug_log!("File permissions: {:?}", metadata.permissions());
}
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true) .open(path)?;
file.write_all((data + "\n").into_bytes().as_ref())?;
file.sync_data()?;
} else {
debug_log!("Could not serialise history: {self:?}");
}
debug_log!("save_to_file({path:?}");
Ok(())
}
#[profiled]
pub fn get_current(&mut self) -> Option<&Entry> {
if self.entries.is_empty() {
return None;
}
if let Some(index) = self.current_index {
debug_log!("get_current(); current index={:?}", self.current_index);
self.get(index)
} else {
debug_log!("None");
None
}
}
#[profiled]
pub fn get(&mut self, index: usize) -> Option<&Entry> {
debug_log!("get({index})...");
if !(0..self.entries.len()).contains(&index) {
return None;
}
self.current_index = Some(index);
debug_log!(
"...get({:?}); current index={:?}",
self.entries.get(index),
self.current_index
);
let entry = self.entries.get(index);
debug_log!("... returning {entry:?}");
entry
}
#[profiled]
pub fn get_mut(&mut self, index: usize) -> Option<&mut Entry> {
debug_log!("get_mut({index})...");
if !(0..self.entries.len()).contains(&index) {
return None;
}
self.current_index = Some(index);
debug_log!(
"...get_mut({:?}); current index={:?}",
self.entries.get(index),
self.current_index
);
let entry = self.entries.get_mut(index);
debug_log!("... returning {entry:?}");
entry
}
#[profiled]
pub fn get_previous(&mut self) -> Option<&Entry> {
debug_log!("get_previous...");
if self.entries.is_empty() {
return None;
}
let new_index = self.current_index.map(|index| {
if index > 0 {
index - 1
} else {
0
}
});
debug_log!(
"...old index={:#?};new_index={new_index:?}",
self.current_index
);
self.current_index = new_index;
self.current_index.map_or_else(
|| {
panic!(
"Logic error: current_index should never be None if there are History records"
);
},
|index| {
let entry = self.get(index);
debug_log!("get_previous; new current index={index:?}, entry={entry:?}");
entry
},
)
}
#[profiled]
pub fn get_next(&mut self) -> Option<&Entry> {
debug_log!("get_next...");
let this = &mut *self;
if this.entries.is_empty() {
return None;
}
let new_index = self.current_index.map(|index| {
let max_index = self.entries.len() - 1;
if index < max_index {
index + 1
} else {
max_index
}
});
debug_log!(
"...old index={:#?};new_index={new_index:?}",
self.current_index
);
self.current_index = new_index;
self.current_index.map_or_else(
|| {
panic!(
"Logic error: current_index should never be None if there are History records"
);
},
|index| {
let entry = self.get(index);
debug_log!("get_next(); current index={index:?}, entry={entry:?}");
entry
},
)
}
#[profiled]
pub fn get_last(&mut self) -> Option<&Entry> {
if self.entries.is_empty() {
return None;
}
self.entries.back()
}
#[profiled]
fn reassign_indices(&mut self) {
for (i, entry) in self.entries.iter_mut().enumerate() {
entry.index = i;
}
}
}
#[allow(dead_code)]
#[derive(Debug, Default)]
pub struct EditData<'a> {
pub return_text: bool,
pub initial_content: &'a str,
pub save_path: Option<PathBuf>,
pub history_path: Option<&'a PathBuf>,
pub history: Option<History>,
}
#[derive(Debug)]
pub struct KeyDisplay<'a> {
pub title: &'a str,
pub title_style: RataStyle,
pub remove_keys: &'a [&'a str],
pub add_keys: &'a [KeyDisplayLine],
}
#[derive(Debug, Default)]
pub struct PopupScrollState {
pub scroll_offset: usize,
}
#[derive(Debug)]
pub enum KeyAction {
AbandonChanges,
Continue, Quit(bool),
Save,
SaveAndExit,
ShowHelp,
SaveAndSubmit,
Submit,
ToggleHighlight,
TogglePopup,
}
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
#[profiled]
pub fn tui_edit<R, F>(
event_reader: &R,
edit_data: &mut EditData,
display: &KeyDisplay,
key_handler: F, ) -> ThagResult<(KeyAction, Option<Vec<String>>)>
where
R: EventReader + Debug,
F: Fn(
ratatui::crossterm::event::KeyEvent,
Option<&mut ManagedTerminal>,
&mut TextArea,
&mut EditData,
&mut bool,
&mut bool,
&mut String,
) -> ThagResult<KeyAction>,
{
let mut popup = false;
let mut tui_highlight_fg: Role = Role::Emphasis;
let mut saved = false;
let mut status_message: String = String::default();
let mut maybe_term = resolve_term()?;
let mut textarea = TextArea::from(edit_data.initial_content.lines());
textarea.set_hard_tab_indent(true);
textarea.set_block(
Block::default()
.borders(Borders::ALL)
.title(display.title)
.title_style(display.title_style),
);
textarea.set_line_number_style(RataStyle::themed(Role::Hint));
textarea.move_cursor(CursorMove::Bottom);
textarea.move_cursor(CursorMove::End);
if !textarea.is_empty() {
textarea.insert_newline();
}
highlight_selection(&mut textarea, tui_highlight_fg);
let remove = display.remove_keys;
let add = display.add_keys;
let mut popup_scroll = PopupScrollState::default();
let mut adjusted_mappings: Vec<KeyDisplayLine> = MAPPINGS
.iter()
.filter(|&row| !remove.contains(&row.keys))
.chain(add.iter())
.cloned()
.collect();
adjusted_mappings.sort();
let (max_key_len, max_desc_len) =
adjusted_mappings
.iter()
.fold((0_u16, 0_u16), |(max_key, max_desc), row| {
let key_len = row.keys.len().try_into().unwrap();
let desc_len = row.desc.len().try_into().unwrap();
(max_key.max(key_len), max_desc.max(desc_len))
});
loop {
maybe_enable_raw_mode()?;
let test_env = &var("TEST_ENV");
let event = if test_env.is_ok() {
event_reader.read_event()?
} else {
maybe_term.as_mut().map_or_else(
|| Err("Logic issue unwrapping term we wrapped ourselves".into()),
|term| {
term.draw(|f| {
let area = f.area();
if area.height > 1 {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints::<&[Constraint]>(&[
Constraint::Min(area.height - 3), Constraint::Length(3), ])
.split(area);
f.render_widget(&textarea, chunks[0]);
let status_block = Block::default()
.borders(Borders::ALL)
.title("Status")
.style(RataStyle::themed(Role::Success))
.title_style(display.title_style)
.padding(ratatui::widgets::Padding::horizontal(1));
let status_text = Paragraph::new::<&str>(status_message.as_ref())
.block(status_block)
.style(RataStyle::themed(Role::Info));
f.render_widget(status_text, chunks[1]);
if popup {
display_popup(
&adjusted_mappings,
TITLE_TOP,
TITLE_BOTTOM,
max_key_len,
max_desc_len,
&mut popup_scroll,
f,
);
}
highlight_selection(&mut textarea, tui_highlight_fg);
}
})
.map_err(|e| {
eprintln!("Error drawing terminal: {e:?}");
e
})?;
event_reader.read_event()
},
)?
};
if let Paste(ref data) = event {
textarea.insert_str(normalize_newlines(data));
} else if let Event::Mouse(mouse_event) = event {
if popup {
use ratatui::crossterm::event::MouseEventKind;
match mouse_event.kind {
MouseEventKind::ScrollDown => {
if popup_scroll.scroll_offset + 1 < adjusted_mappings.len() {
popup_scroll.scroll_offset += 1;
}
}
MouseEventKind::ScrollUp => {
popup_scroll.scroll_offset = popup_scroll.scroll_offset.saturating_sub(1);
}
_ => {}
}
}
} else if let Event::Key(key_event) = event {
if !matches!(key_event.kind, KeyEventKind::Press) {
continue;
}
let key_combination = KeyCombination::from(key_event);
if popup {
let max_scroll = adjusted_mappings.len().saturating_sub(10);
match key_combination {
key!(up) => {
popup_scroll.scroll_offset = popup_scroll.scroll_offset.saturating_sub(1);
continue;
}
key!(down) => {
if popup_scroll.scroll_offset < max_scroll {
popup_scroll.scroll_offset += 1;
}
continue;
}
_ => {} }
}
#[allow(clippy::unnested_or_patterns)]
match key_combination {
key!(ctrl - h) | key!(backspace) => {
textarea.delete_char();
}
key!(ctrl - m) | key!(enter) => {
textarea.insert_newline();
}
key!(ctrl - k) => {
textarea.delete_line_by_end();
}
key!(ctrl - j) => {
textarea.delete_line_by_head();
}
key!(ctrl - w) | key!(alt - backspace) => {
textarea.delete_word();
}
key!(alt - d) => {
textarea.delete_next_word();
}
key!(ctrl - u) => {
textarea.undo();
}
key!(ctrl - r) => {
textarea.redo();
}
key!(ctrl - c) => {
textarea.copy();
}
key!(ctrl - x) => {
textarea.cut();
}
key!(ctrl - y) => {
textarea.paste();
}
key!(ctrl - f) | key!(right) => {
if textarea.is_selecting() {
textarea.cancel_selection();
}
textarea.move_cursor(CursorMove::Forward);
}
key!(ctrl - b) | key!(left) => {
if textarea.is_selecting() {
textarea.cancel_selection();
}
textarea.move_cursor(CursorMove::Back);
}
key!(ctrl - p) | key!(up) => {
if textarea.is_selecting() {
textarea.cancel_selection();
}
textarea.move_cursor(CursorMove::Up);
}
key!(ctrl - n) | key!(down) => {
if textarea.is_selecting() {
textarea.cancel_selection();
}
textarea.move_cursor(CursorMove::Down);
}
key!(alt - f) => {
if textarea.is_selecting() {
textarea.cancel_selection();
}
textarea.move_cursor(CursorMove::WordForward);
}
key!(alt - shift - f) => {
textarea.move_cursor(CursorMove::WordEnd);
}
key!(alt - b) => {
if textarea.is_selecting() {
textarea.cancel_selection();
}
textarea.move_cursor(CursorMove::WordBack);
}
key!(alt - p) | key!(alt - ')') | key!(f1) => {
if textarea.is_selecting() {
textarea.cancel_selection();
}
textarea.move_cursor(CursorMove::ParagraphBack);
}
key!(alt - n) | key!(alt - '(') | key!(f2) => {
textarea.move_cursor(CursorMove::ParagraphForward);
}
key!(ctrl - e) | key!(end) | key!(ctrl - alt - f) => {
textarea.move_cursor(CursorMove::End);
}
key!(ctrl - a) | key!(home) | key!(ctrl - alt - b) => {
textarea.move_cursor(CursorMove::Head);
}
key!(f9) => {
ratatui::crossterm::execute!(std::io::stdout().lock(), DisableMouseCapture,)?;
textarea.remove_line_number();
textarea.set_block(
Block::default()
.borders(Borders::NONE)
.title(display.title)
.title_style(display.title_style),
);
}
key!(f10) => {
ratatui::crossterm::execute!(std::io::stdout().lock(), EnableMouseCapture,)?;
textarea.set_line_number_style(RataStyle::themed(Role::Hint));
textarea.set_block(
Block::default()
.borders(Borders::ALL)
.title(display.title)
.title_style(display.title_style),
);
}
key!(alt - '<') | key!(ctrl - alt - p) => {
textarea.move_cursor(CursorMove::Top);
}
key!(alt - '>') | key!(ctrl - alt - n) => {
textarea.move_cursor(CursorMove::Bottom);
}
key!(alt - c) => {
if textarea.is_selecting() {
textarea.cancel_selection();
} else {
textarea.start_selection();
}
}
key!(alt - shift - 'h') => {
if !textarea.is_selecting() {
textarea.start_selection();
}
textarea.move_cursor(CursorMove::WordBack);
}
key!(alt - shift - 'j') => {
if !textarea.is_selecting() {
textarea.start_selection();
}
textarea.move_cursor(CursorMove::Down);
}
key!(alt - shift - 'k') => {
if !textarea.is_selecting() {
textarea.start_selection();
}
textarea.move_cursor(CursorMove::Up);
}
key!(alt - shift - 'l') => {
if !textarea.is_selecting() {
textarea.start_selection();
}
textarea.move_cursor(CursorMove::WordEnd);
}
key!(alt - shift - 'p') => {
if !textarea.is_selecting() {
textarea.start_selection();
}
textarea.move_cursor(CursorMove::ParagraphBack);
}
key!(alt - shift - 'n') => {
if !textarea.is_selecting() {
textarea.start_selection();
}
textarea.move_cursor(CursorMove::ParagraphForward);
}
key!(alt - shift - a) => {
textarea.select_all();
}
key!(ctrl - t) => {
tui_highlight_fg = match tui_highlight_fg {
Role::Emphasis => Role::Info,
Role::Info => Role::Error,
Role::Error => Role::Warning,
Role::Warning => Role::Heading1,
Role::Heading1 => Role::Heading2,
Role::Heading2 => Role::Heading3,
_ => Role::Emphasis,
};
if var("TEST_ENV").is_err() {
#[allow(clippy::option_if_let_else)]
if let Some(ref mut term) = maybe_term {
term.draw(|_| {
highlight_selection(&mut textarea, tui_highlight_fg);
})?;
}
}
}
_ => {
let key_action = key_handler(
key_event,
maybe_term.as_mut(),
&mut textarea,
edit_data,
&mut popup,
&mut saved,
&mut status_message,
)?;
match key_action {
KeyAction::AbandonChanges => break Ok((key_action, None::<Vec<String>>)),
KeyAction::Quit(_)
| KeyAction::SaveAndExit
| KeyAction::SaveAndSubmit
| KeyAction::Submit => {
let maybe_text = if edit_data.return_text {
Some(textarea.lines().to_vec())
} else {
None::<Vec<String>>
};
break Ok((key_action, maybe_text));
}
KeyAction::Continue | KeyAction::Save | KeyAction::ToggleHighlight => (),
KeyAction::TogglePopup => {
if popup {
popup_scroll.scroll_offset = 0;
}
}
KeyAction::ShowHelp => todo!(),
}
}
}
} else {
let input = tui_textarea::Input::from(event);
textarea.input(input);
}
}
}
#[profiled]
pub fn highlight_selection(textarea: &mut TextArea<'_>, tui_highlight_fg: Role) {
textarea.set_selection_style(RataStyle::themed(tui_highlight_fg).bold());
}
#[allow(clippy::too_many_lines, clippy::missing_panics_doc)]
#[profiled]
pub fn script_key_handler(
key_event: KeyEvent,
maybe_term: Option<&mut ManagedTerminal>,
textarea: &mut TextArea,
edit_data: &mut EditData,
popup: &mut bool,
saved: &mut bool, status_message: &mut String,
) -> ThagResult<KeyAction> {
if !matches!(key_event.kind, event::KeyEventKind::Press) {
return Ok(KeyAction::Continue);
}
let key_combination = KeyCombination::from(key_event);
let history_path = edit_data.history_path.cloned();
#[allow(clippy::unnested_or_patterns)]
match key_combination {
key!(esc) | key!(ctrl - q) => Ok(KeyAction::Quit(*saved)),
key!(ctrl - d) => save_and_submit(history_path.as_ref(), edit_data, textarea),
key!(ctrl - s) | key!(ctrl - alt - s) | key!(f12) => {
if matches!(key_combination, key!(ctrl - s)) && edit_data.save_path.is_some() {
save(
edit_data,
history_path.as_ref(),
textarea,
saved,
status_message,
)
} else {
let key_action = save_as(edit_data, maybe_term, textarea, saved, status_message)?;
Ok(key_action)
}
}
key!(ctrl - l) => {
*popup = !*popup;
Ok(KeyAction::TogglePopup)
}
key!(f3) => {
Ok(KeyAction::AbandonChanges)
}
key!(f4) => {
textarea.select_all();
textarea.cut();
Ok(KeyAction::Continue)
}
key!(f5) => {
if textarea.is_empty() {
return Ok(KeyAction::Continue);
}
wipe_textarea(edit_data, textarea, history_path.as_ref())?;
Ok(KeyAction::Continue)
}
key!(f6) => {
edit_history()?;
Ok(KeyAction::Continue)
}
key!(f7) => {
prev_hist(edit_data, textarea, history_path.as_ref())?;
Ok(KeyAction::Continue)
}
key!(f8) => {
next_hist(edit_data, textarea);
Ok(KeyAction::Continue)
}
_ => {
textarea.input(Input::from(key_event)); Ok(KeyAction::Continue)
}
}
}
#[profiled]
fn next_hist(edit_data: &mut EditData<'_>, textarea: &mut TextArea<'_>) {
if let Some(ref mut hist) = edit_data.history {
if let Some(entry) = hist.get_next() {
debug_log!("F8 found entry {entry:?}");
paste_to_textarea(textarea, entry);
}
}
}
#[profiled]
fn prev_hist(
edit_data: &mut EditData<'_>,
textarea: &mut TextArea<'_>,
history_path: Option<&PathBuf>,
) -> ThagResult<()> {
if let Some(ref mut hist) = edit_data.history {
if hist.at_end() && textarea.is_empty() {
if let Some(entry) = &hist.get_last() {
debug_log!("F7 (1) found entry {entry:?}");
paste_to_textarea(textarea, entry);
}
} else {
save_if_changed(hist, textarea, history_path)?;
if let Some(entry) = &hist.get_previous() {
debug_log!("F7 (2) found entry {entry:?}");
paste_to_textarea(textarea, entry);
}
}
}
Ok(())
}
#[profiled]
fn wipe_textarea(
edit_data: &mut EditData<'_>,
textarea: &mut TextArea<'_>,
history_path: Option<&PathBuf>,
) -> ThagResult<()> {
if let Some(ref mut hist) = edit_data.history {
let _in_hist = !&hist.at_end();
let textarea_contents = textarea.lines().to_vec().join("\n");
textarea.select_all();
textarea.cut();
let yank_text = textarea.yank_text();
assert_eq!(yank_text, textarea_contents);
if let Some(current_hist_entry) = &hist.get_current() {
assert_eq!(yank_text, current_hist_entry.contents());
let index = current_hist_entry.index;
hist.delete_entry(index);
hist.entries
.retain(|f| f.contents().trim() != textarea_contents);
}
if let Some(hist_path) = history_path {
hist.save_to_file(hist_path)?;
}
}
Ok(())
}
#[profiled]
fn save_as(
edit_data: &mut EditData<'_>,
maybe_term: Option<&mut ManagedTerminal<'_>>,
textarea: &mut TextArea<'_>,
saved: &mut bool,
status_message: &mut String,
) -> ThagResult<KeyAction> {
if let Some(term) = maybe_term {
let mut save_dialog: FileDialog<'_> = FileDialog::new(60, 20, DialogMode::Save)?;
save_dialog.open();
let mut status = Status::Incomplete;
while matches!(status, Status::Incomplete) && save_dialog.selected_file.is_none() {
term.draw(|f| save_dialog.draw(f))?;
if let Event::Key(key) = event::read()? {
status = save_dialog.handle_input(key)?;
}
}
status_message.clear();
if let Some(ref to_rs_path) = save_dialog.selected_file {
save_source_file(to_rs_path, textarea, saved)?;
let _ = write!(status_message, "Saved to {}", to_rs_path.display());
edit_data.save_path = Some(to_rs_path.clone());
Ok(KeyAction::Save)
} else {
let _ = write!(status_message, "Failed to save file");
Ok(KeyAction::Continue)
}
} else {
let _ = write!(status_message, "No terminal to display file save dialog");
Ok(KeyAction::Continue)
}
}
#[profiled]
fn save(
edit_data: &mut EditData<'_>,
history_path: Option<&PathBuf>,
textarea: &mut TextArea<'_>,
saved: &mut bool,
status_message: &mut String,
) -> ThagResult<KeyAction> {
if let Some(ref save_path) = edit_data.save_path {
if let Some(hist_path) = history_path {
let history = &mut edit_data.history;
if let Some(hist) = history {
preserve(textarea, hist, hist_path)?;
}
}
let result = save_source_file(save_path, textarea, saved);
match result {
Ok(()) => {
status_message.clear();
let _ = write!(status_message, "Saved to {}", save_path.display());
Ok(KeyAction::Save)
}
Err(e) => Err(e),
}
} else {
status_message.clear();
let _ = write!(
status_message,
"No save path: edit_data.save_path={:?}",
edit_data.save_path
);
Ok(KeyAction::Continue)
}
}
#[profiled]
fn save_and_submit(
history_path: Option<&PathBuf>,
edit_data: &mut EditData<'_>,
textarea: &mut TextArea<'_>,
) -> ThagResult<KeyAction> {
if let Some(hist_path) = history_path {
let history = &mut edit_data.history;
if let Some(hist) = history {
preserve(textarea, hist, hist_path)?;
}
}
Ok(KeyAction::Submit)
}
#[profiled]
pub fn maybe_enable_raw_mode() -> ThagResult<()> {
let test_env = &var("TEST_ENV");
debug_log!("test_env={test_env:?}");
if !test_env.is_ok() && !is_raw_mode_enabled()? {
if std::io::IsTerminal::is_terminal(&std::io::stdout()) {
debug_log!("Enabling raw mode");
enable_raw_mode()?;
} else {
debug_log!("Skipping raw mode - not a terminal");
}
}
Ok(())
}
#[profiled]
#[allow(clippy::cast_possible_truncation)]
pub fn display_popup(
mappings: &[KeyDisplayLine],
title_top: &str,
title_bottom: &str,
max_key_len: u16,
max_desc_len: u16,
scroll_state: &mut PopupScrollState,
f: &mut ratatui::prelude::Frame<'_>,
) {
let total_rows = mappings.len();
let max_height = f.area().height.saturating_sub(6); let content_height = max_height.min(total_rows as u16);
let block = Block::default()
.borders(Borders::ALL)
.title_top(Line::from(title_top).centered())
.title_bottom(Line::from(format!("{title_bottom} (scroll with mouse wheel)")).centered())
.add_modifier(Modifier::BOLD)
.fg(Color::themed(Role::HD1));
#[allow(clippy::cast_possible_truncation)]
let area = centered_rect(max_key_len + max_desc_len + 5, content_height + 5, f.area());
let inner = area.inner(Margin {
vertical: 2,
horizontal: 2,
});
f.render_widget(Clear, area);
f.render_widget(block, area);
let visible_rows = inner.height as usize;
let max_scroll = total_rows.saturating_sub(visible_rows);
scroll_state.scroll_offset = scroll_state.scroll_offset.min(max_scroll);
let start_idx = scroll_state.scroll_offset;
let end_idx = (start_idx + visible_rows).min(total_rows);
let visible_mappings = &mappings[start_idx..end_idx];
#[allow(clippy::cast_possible_truncation)]
let row_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(std::iter::repeat_n(
Constraint::Length(1),
visible_mappings.len(),
));
let rows = row_layout.split(inner);
for (i, row) in rows.iter().enumerate() {
let actual_idx = start_idx + i;
let col_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints::<&[Constraint]>(&[
Constraint::Length(max_key_len + 1),
Constraint::Length(max_desc_len),
]);
let cells = col_layout.split(*row);
let mut widget = Paragraph::new(visible_mappings[i].keys);
if actual_idx == 0 {
widget = widget
.add_modifier(Modifier::BOLD)
.fg(Color::themed(Role::EMPH));
} else {
widget = widget.fg(Color::themed(Role::HD2)).not_bold();
}
f.render_widget(widget, cells[0]);
let mut widget = Paragraph::new(visible_mappings[i].desc);
if actual_idx == 0 {
widget = widget
.add_modifier(Modifier::BOLD)
.fg(Color::themed(Role::EMPH));
} else {
widget = widget
.remove_modifier(Modifier::BOLD)
.set_style(RataStyle::themed(Role::INFO).not_bold());
}
f.render_widget(widget, cells[1]);
}
}
#[must_use]
#[profiled]
pub fn centered_rect(max_width: u16, max_height: u16, r: Rect) -> Rect {
let popup_layout = Layout::vertical([
Constraint::Fill(1),
Constraint::Max(max_height),
Constraint::Fill(1),
])
.split(r);
Layout::horizontal([
Constraint::Fill(1),
Constraint::Max(max_width),
Constraint::Fill(1),
])
.split(popup_layout[1])[1]
}
#[must_use]
#[profiled]
pub fn normalize_newlines(input: &str) -> String {
let re: &Regex = re!(r"\r\n?");
re.replace_all(input, "\n").to_string()
}
#[profiled]
pub fn reset_term(mut term: Terminal<CrosstermBackend<std::io::StdoutLock<'_>>>) -> ThagResult<()> {
disable_raw_mode()?;
ratatui::crossterm::execute!(
term.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
term.show_cursor()?;
Ok(())
}
#[profiled]
pub fn save_if_changed(
hist: &mut History,
textarea: &mut TextArea<'_>,
history_path: Option<&PathBuf>,
) -> ThagResult<()> {
debug_log!("save_if_changed...");
if textarea.is_empty() {
debug_log!("nothing to save(1)...");
return Ok(());
}
if let Some(entry) = &hist.get_current() {
let index = entry.index;
let copy_text = copy_text(textarea);
if copy_text.trim().is_empty() {
debug_log!("nothing to save(2)...");
return Ok(());
}
if entry.contents() != copy_text {
hist.update_entry(index, ©_text);
if let Some(hist_path) = history_path {
hist.save_to_file(hist_path)?;
}
}
}
Ok(())
}
#[profiled]
pub fn paste_to_textarea(textarea: &mut TextArea<'_>, entry: &Entry) {
textarea.select_all();
textarea.cut();
textarea.insert_str(entry.contents());
}
#[profiled]
pub fn preserve(
textarea: &mut TextArea<'_>,
hist: &mut History,
history_path: &PathBuf,
) -> ThagResult<()> {
debug_log!("preserve...");
save_if_not_empty(textarea, hist);
save_history(Some(&mut hist.clone()), Some(history_path))?;
Ok(())
}
#[profiled]
pub fn save_if_not_empty(textarea: &mut TextArea<'_>, hist: &mut History) {
debug_log!("save_if_not_empty...");
let text = copy_text(textarea);
if !text.trim().is_empty() {
hist.add_entry(&text);
debug_log!("... added entry");
}
}
#[profiled]
pub fn copy_text(textarea: &mut TextArea<'_>) -> String {
textarea.select_all();
textarea.copy();
let text = textarea.yank_text().lines().collect::<Vec<_>>().join("\n");
text
}
#[profiled]
pub fn save_history(
history: Option<&mut History>,
history_path: Option<&PathBuf>,
) -> ThagResult<()> {
debug_log!("save_history...{history:?}");
if let Some(hist) = history {
if let Some(hist_path) = history_path {
hist.save_to_file(hist_path)?;
debug_log!("... saved to file");
}
}
Ok(())
}
#[profiled]
pub fn save_source_file(
to_rs_path: &PathBuf,
textarea: &mut TextArea<'_>,
saved: &mut bool,
) -> ThagResult<()> {
textarea.move_cursor(CursorMove::Bottom);
textarea.move_cursor(CursorMove::End);
if textarea.cursor().1 != 0 {
textarea.insert_newline();
}
let _write_source = write_source(to_rs_path, textarea.lines().join("\n").as_str())?;
*saved = true;
Ok(())
}
#[macro_export]
macro_rules! key_mappings {
(
$(($seq:expr, $keys:expr, $desc:expr)),* $(,)?
) => {
&[
$(
KeyDisplayLine {
seq: $seq,
keys: $keys,
desc: $desc,
}
),*
]
};
}
pub const MAPPINGS: &[KeyDisplayLine] = key_mappings![
(10, "Key bindings", "Description"),
(
20,
"Shift+arrow keys",
"Select/deselect chars (←→) or lines (↑↓)"
),
(
30,
"Alt+shift+ h/j/k/l",
"Select/deselect words (←h l→) or lines (↑k j↓)"
),
(35, "Alt+shift+ p/n", "Select/deselect paras (↑p n↓)"),
(40, "Alt+Shift+a", "Select all"),
(50, "Alt+c", "Cancel selection"),
(60, "Ctrl+d", "Submit"),
(70, "Ctrl+q", "Cancel and quit"),
(80, "Ctrl+h, Backspace", "Delete character before cursor"),
(90, "Ctrl+i, Tab", "Indent"),
(100, "Ctrl+m, Enter", "Insert newline"),
(110, "Ctrl+k", "Delete from cursor to end of line"),
(120, "Ctrl+j", "Delete from cursor to start of line"),
(
130,
"Ctrl+w, Alt+Backspace",
"Delete one word before cursor"
),
(140, "Alt+d, Delete", "Delete one word from cursor position"),
(150, "Ctrl+u", "Undo"),
(160, "Ctrl+r", "Redo"),
(170, "Ctrl+c", "Copy (yank) selected text"),
(180, "Ctrl+x", "Cut (yank) selected text"),
(190, "Ctrl+y", "Paste yanked text"),
(
200,
"Ctrl+v, Shift+Ins, Cmd+v",
"Paste from system clipboard according to platform"
),
(210, "Ctrl+f, →", "Move cursor forward one character"),
(220, "Ctrl+b, ←", "Move cursor backward one character"),
(230, "Ctrl+p, ↑", "Move cursor up one line"),
(240, "Ctrl+n, ↓", "Move cursor down one line"),
(250, "Alt+f", "Move cursor forward one word"),
(260, "Alt+Shift+f", "Move cursor to next word end"),
(270, "Atl+b", "Move cursor backward one word"),
(280, "Alt+p", "Move cursor up one paragraph"),
(290, "Alt+n", "Move cursor down one paragraph"),
(300, "Ctrl+e, End, Ctrl+Alt+f", "Move cursor to end of line"),
(
310,
"Ctrl+a, Home, Ctrl+Alt+b",
"Move cursor to start of line"
),
(320, "Alt+<, Ctrl+Alt+p", "Move cursor to top of file"),
(330, "Alt+>, Ctrl+Alt+n", "Move cursor to bottom of file"),
(340, "Ctrl+l", "Toggle keys display (this screen)"),
(350, "Ctrl+t", "Toggle selection highlight colours"),
(360, "Alt+v, PageUp, F1", "Page up"),
(370, "PageDown, F2", "Page down"),
(380, "F4", "Clear text buffer (Ctrl+y or Ctrl+u to restore)"),
(
390,
"F5",
"Clear and wipe from history (Ctrl+y or Ctrl+u to restore text buffer)"
),
(400, "F6", "Edit history"),
(410, "F7", "Previous in history"),
(420, "F8", "Next in history"),
(
430,
"F9",
"Enter `copy to system clipboard` mode with mouse selection and OS keys"
),
(440, "F10", "Exit `copy to system clipboard` mode"),
(450, "F12", "Save as..."),
];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeyDisplayLine {
pub seq: usize,
pub keys: &'static str, pub desc: &'static str, }
impl PartialOrd for KeyDisplayLine {
#[profiled]
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for KeyDisplayLine {
#[profiled]
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
usize::cmp(&self.seq, &other.seq)
}
}
impl KeyDisplayLine {
#[must_use]
pub const fn new(seq: usize, keys: &'static str, desc: &'static str) -> Self {
Self { seq, keys, desc }
}
}