use crate::code_utils::write_source;
use crate::file_dialog::{DialogMode, FileDialog, Status};
use crate::{
debug_log, key, regex, EventReader, KeyCombination, KeyDisplayLine, Lvl, ThagError, ThagResult,
};
use crossterm::event::{
self, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event::{self, Paste},
KeyEvent, KeyEventKind,
};
use 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};
use ratatui::style::{Color, Modifier, Style, Styled, Stylize};
use ratatui::text::Line;
use ratatui::widgets::{block::Block, Borders, Clear, Paragraph};
use ratatui::Terminal;
use regex::Regex;
use scopeguard::{guard, ScopeGuard};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::convert::Into;
use std::env::var;
use std::fmt::{Debug, Display};
use std::io::Write;
use std::path::PathBuf;
use std::{
self,
fs::{self, OpenOptions},
};
use tui_textarea::{CursorMove, Input, TextArea};
pub type BackEnd = CrosstermBackend<std::io::StdoutLock<'static>>;
pub type Term = Terminal<BackEnd>;
pub type ResetTermClosure = Box<dyn FnOnce(Term)>;
pub type TermScopeGuard = ScopeGuard<Term, ResetTermClosure>;
pub const TITLE_TOP: &str = "Key bindings - subject to your terminal settings";
pub const TITLE_BOTTOM: &str = "Ctrl+l to hide";
pub fn resolve_term() -> ThagResult<Option<TermScopeGuard>> {
let maybe_term = if var("TEST_ENV").is_ok() {
None
} else {
let mut stdout = std::io::stdout().lock();
enable_raw_mode()?;
crossterm::execute!(
stdout,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste
)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
let term = guard(
terminal,
Box::new(|term| {
reset_term(term).expect("Error resetting terminal");
}) as ResetTermClosure,
);
Some(term)
};
Ok(maybe_term)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Entry {
pub index: usize, pub lines: Vec<String>, }
impl Display for Entry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}: {}", self.index, self.lines.join("\n"))
}
}
impl Entry {
pub fn new(index: usize, content: &str) -> Self {
Self {
index,
lines: content.lines().map(String::from).collect(),
}
}
#[must_use]
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]
pub fn new() -> Self {
Self {
current_index: None,
entries: VecDeque::with_capacity(20),
}
}
#[must_use]
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
}
#[must_use]
pub fn at_start(&self) -> bool {
debug_log!("at_start ...");
self.current_index
.map_or(true, |current_index| current_index == 0)
}
#[must_use]
pub fn at_end(&self) -> bool {
debug_log!("at_end ...");
self.current_index.map_or(true, |current_index| {
current_index == self.entries.len() - 1
})
}
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:?}");
}
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);
}
}
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);
}
}
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(())
}
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
}
}
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
}
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
}
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
},
)
}
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
},
)
}
pub fn get_last(&mut self) -> Option<&Entry> {
if self.entries.is_empty() {
return None;
}
self.entries.back()
}
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<&'a mut PathBuf>,
pub history_path: Option<&'a PathBuf>,
pub history: Option<History>,
}
#[derive(Debug)]
pub struct KeyDisplay<'a> {
pub title: &'a str,
pub title_style: Style,
pub remove_keys: &'a [&'a str],
pub add_keys: &'a [KeyDisplayLine],
}
#[derive(Debug)]
pub enum KeyAction {
AbandonChanges,
Continue, Quit(bool),
Save,
SaveAndExit,
ShowHelp,
SaveAndSubmit,
Submit,
ToggleHighlight,
TogglePopup,
}
#[allow(clippy::too_many_lines)]
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(
KeyEvent,
&mut Option<&mut TermScopeGuard>,
&mut TextArea,
&mut EditData,
&mut bool,
&mut bool,
&mut String,
) -> ThagResult<KeyAction>,
{
let mut popup = false;
let mut tui_highlight_fg: Lvl = Lvl::EMPH;
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_block(
Block::default()
.borders(Borders::ALL)
.title(display.title)
.title_style(display.title_style),
);
textarea.set_line_number_style(Style::default().fg(Color::DarkGray));
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 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::Min(area.height - 3), Constraint::Length(3), ]
.as_ref(),
)
.split(area);
f.render_widget(&textarea, chunks[0]);
let status_block = Block::default()
.borders(Borders::ALL)
.title("Status")
.style(Style::default().fg(Color::White))
.title_style(display.title_style);
let status_text = Paragraph::new::<&str>(status_message.as_ref())
.block(status_block)
.style(Style::default().fg(Color::White));
f.render_widget(status_text, chunks[1]);
if popup {
display_popup(
&adjusted_mappings,
TITLE_TOP,
TITLE_BOTTOM,
max_key_len,
max_desc_len,
f,
);
};
highlight_selection(&mut textarea, tui_highlight_fg);
}
})
.map_err(|e| {
eprintln!("Error drawing terminal: {e:?}");
e
})?;
let event = event_reader.read_event();
event.map_err(Into::<ThagError>::into)
},
)?
};
if let Paste(ref data) = event {
textarea.insert_str(normalize_newlines(data));
} else if let Event::Key(key_event) = event {
if !matches!(key_event.kind, KeyEventKind::Press) {
continue;
}
let key_combination = KeyCombination::from(key_event);
#[allow(clippy::unnested_or_patterns)]
match key_combination {
key!(ctrl - h) | key!(backspace) => {
textarea.delete_char();
}
key!(ctrl - i) | key!(tab) => {
textarea.indent();
}
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.yank_text();
}
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) | key!(ctrl - right) => {
if textarea.is_selecting() {
textarea.cancel_selection();
}
textarea.move_cursor(CursorMove::WordForward);
}
key!(alt - shift - f) => {
textarea.move_cursor(CursorMove::WordEnd);
}
key!(alt - b) | key!(ctrl - left) => {
if textarea.is_selecting() {
textarea.cancel_selection();
}
textarea.move_cursor(CursorMove::WordBack);
}
key!(alt - p) | key!(alt - ')') | key!(ctrl - up) => {
if textarea.is_selecting() {
textarea.cancel_selection();
}
textarea.move_cursor(CursorMove::ParagraphBack);
}
key!(alt - n) | key!(alt - '(') | key!(ctrl - down) => {
textarea.move_cursor(CursorMove::ParagraphForward);
}
key!(ctrl - e) | key!(end) | key!(ctrl - alt - f) | key!(ctrl - alt - right) => {
textarea.move_cursor(CursorMove::End);
}
key!(ctrl - a) | key!(home) | key!(ctrl - alt - b) | key!(ctrl - alt - left) => {
textarea.move_cursor(CursorMove::Head);
}
key!(f9) => {
if maybe_term.is_some() {
crossterm::execute!(std::io::stdout().lock(), DisableMouseCapture,)?;
textarea.remove_line_number();
}
}
key!(f10) => {
if maybe_term.is_some() {
crossterm::execute!(std::io::stdout().lock(), EnableMouseCapture,)?;
textarea.set_line_number_style(Style::default().fg(Color::DarkGray));
}
}
key!(alt - '<') | key!(ctrl - alt - p) | key!(ctrl - alt - up) => {
textarea.move_cursor(CursorMove::Top);
}
key!(alt - '>') | key!(ctrl - alt - n) | key!(ctrl - alt - down) => {
textarea.move_cursor(CursorMove::Bottom);
}
key!(alt - c) => {
if textarea.is_selecting() {
textarea.cancel_selection();
} else {
textarea.start_selection();
}
}
key!(alt - shift - a) => {
textarea.select_all();
}
key!(ctrl - t) => {
tui_highlight_fg = match tui_highlight_fg {
Lvl::EMPH => Lvl::BRI,
Lvl::BRI => Lvl::ERR,
Lvl::ERR => Lvl::WARN,
Lvl::WARN => Lvl::HEAD,
Lvl::HEAD => Lvl::SUBH,
_ => Lvl::EMPH,
};
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,
&mut 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 => continue,
KeyAction::ShowHelp => todo!(),
}
}
}
} else {
let input = Input::from(event);
textarea.input(input);
}
}
}
pub fn highlight_selection(textarea: &mut TextArea<'_>, tui_highlight_fg: crate::MessageLevel) {
textarea.set_selection_style(
Style::default()
.fg(Color::Indexed(u8::from(&tui_highlight_fg)))
.bold(),
);
}
#[allow(clippy::too_many_lines)]
pub fn script_key_handler(
key_event: KeyEvent,
maybe_term: &mut Option<&mut TermScopeGuard>,
textarea: &mut TextArea,
edit_data: &mut EditData,
popup: &mut bool,
saved: &mut bool, status_message: &mut String,
) -> ThagResult<KeyAction> {
if !matches!(key_event.kind, 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 - c) | key!(ctrl - q) => Ok(KeyAction::Quit(*saved)),
key!(ctrl - d) => {
if let Some(ref hist_path) = history_path {
let history = &mut edit_data.history;
if let Some(hist) = history {
preserve(textarea, hist, hist_path)?;
};
}
Ok(KeyAction::Submit)
}
key!(ctrl - s) | key!(ctrl - alt - s) => {
if matches!(key_combination, key!(ctrl - s)) {
if let Some(ref mut save_path) = edit_data.save_path {
if let Some(ref 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();
status_message
.push_str(&format!("Saved to {}", save_path.display()));
}
Err(e) => return Err(e),
}
}
}
Ok(KeyAction::Save)
} else if let Some(term) = maybe_term {
let mut save_dialog: FileDialog<'_> = FileDialog::new(60, 40, 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)?;
}
}
if let Some(ref to_rs_path) = save_dialog.selected_file {
save_source_file(to_rs_path, textarea, saved)?;
status_message.clear();
status_message.push_str(&format!("Saved to {}", to_rs_path.display()));
Ok(KeyAction::Save)
} else {
Ok(KeyAction::Continue)
}
} else {
Ok(KeyAction::Continue)
}
}
key!(ctrl - l) => {
*popup = !*popup;
Ok(KeyAction::TogglePopup)
}
key!(f3) => {
Ok(KeyAction::AbandonChanges)
}
key!(f4) => {
textarea.select_all();
textarea.cut();
Ok(KeyAction::Continue)
}
key!(f7) => {
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(KeyAction::Continue)
}
key!(f8) => {
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);
}
}
Ok(KeyAction::Continue)
}
_ => {
textarea.input(Input::from(key_event)); Ok(KeyAction::Continue)
}
}
}
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()? {
debug_log!("Enabling raw mode");
enable_raw_mode()?;
}
Ok(())
}
pub fn display_popup(
mappings: &[KeyDisplayLine],
title_top: &str,
title_bottom: &str,
max_key_len: u16,
max_desc_len: u16,
f: &mut ratatui::prelude::Frame<'_>,
) {
let num_filtered_rows = mappings.len();
let block = Block::default()
.borders(Borders::ALL)
.title_top(Line::from(title_top).centered())
.title_bottom(Line::from(title_bottom).centered())
.add_modifier(Modifier::BOLD)
.fg(Color::Indexed(u8::from(&Lvl::HEAD)));
#[allow(clippy::cast_possible_truncation)]
let area = centered_rect(
max_key_len + max_desc_len + 5,
num_filtered_rows as u16 + 5,
f.area(),
);
let inner = area.inner(Margin {
vertical: 2,
horizontal: 2,
});
f.render_widget(Clear, area);
f.render_widget(block, area);
#[allow(clippy::cast_possible_truncation)]
let row_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
std::iter::repeat(Constraint::Ratio(1, num_filtered_rows as u32))
.take(num_filtered_rows),
);
let rows = row_layout.split(inner);
for (i, row) in rows.iter().enumerate() {
let col_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Length(max_key_len + 1),
Constraint::Length(max_desc_len),
]
.as_ref(),
);
let cells = col_layout.split(*row);
let mut widget = Paragraph::new(mappings[i].keys);
if i == 0 {
widget = widget
.add_modifier(Modifier::BOLD)
.fg(Color::Indexed(u8::from(&Lvl::EMPH)));
} else {
widget = widget.fg(Color::Indexed(u8::from(&Lvl::SUBH))).not_bold();
}
f.render_widget(widget, cells[0]);
let mut widget = Paragraph::new(mappings[i].desc);
if i == 0 {
widget = widget
.add_modifier(Modifier::BOLD)
.fg(Color::Indexed(u8::from(&Lvl::EMPH)));
} else {
widget = widget.remove_modifier(Modifier::BOLD).set_style(
Style::default()
.fg(Color::Indexed(u8::from(&Lvl::NORM)))
.not_bold(),
);
}
f.render_widget(widget, cells[1]);
}
}
#[must_use]
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]
pub fn normalize_newlines(input: &str) -> String {
let re: &Regex = regex!(r"\r\n?");
re.replace_all(input, "\n").to_string()
}
pub fn reset_term(mut term: Terminal<CrosstermBackend<std::io::StdoutLock<'_>>>) -> ThagResult<()> {
disable_raw_mode()?;
crossterm::execute!(
term.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
term.show_cursor()?;
Ok(())
}
pub fn save_if_changed(
hist: &mut History,
textarea: &mut TextArea<'_>,
history_path: &Option<PathBuf>,
) -> Result<(), ThagError> {
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(ref hist_path) = history_path {
hist.save_to_file(hist_path)?;
}
}
}
Ok(())
}
pub fn paste_to_textarea(textarea: &mut TextArea<'_>, entry: &Entry) {
textarea.select_all();
textarea.cut();
textarea.insert_str(entry.contents());
}
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(())
}
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");
}
}
pub fn copy_text(textarea: &mut TextArea<'_>) -> String {
textarea.select_all();
textarea.copy();
let text = textarea.yank_text().lines().collect::<Vec<_>>().join("\n");
text
}
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(())
}
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,
"Shift+Ctrl+arrow keys",
"Select/deselect words (←→) or paras (↑↓)"
),
(40, "Alt+a", "Select all"),
(
50,
"Alt+c",
"Toggle selection mode: start selecting / 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"
),
(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, Ctrl+→", "Move cursor forward one word"),
(260, "Alt+Shift+f", "Move cursor to next word end"),
(270, "Atl+b, Ctrl+←", "Move cursor backward one word"),
(280, "Alt+) or p, Ctrl+↑", "Move cursor up one paragraph"),
(290, "Alt+( or n, Ctrl+↓", "Move cursor down one paragraph"),
(
300,
"Ctrl+e, End, Ctrl+Alt+f or → , Cmd+→",
"Move cursor to end of line"
),
(
310,
"Ctrl+a, Home, Ctrl+Alt+b or ← , Cmd+←",
"Move cursor to start of line"
),
(320, "Alt+<, Ctrl+Alt+p or ↑", "Move cursor to top of file"),
(
330,
"Alt+>, Ctrl+Alt+n or ↓",
"Move cursor to bottom of file"
),
(340, "PageDown, Cmd+↓", "Page down"),
(350, "Alt+v, PageUp, Cmd+↑", "Page up"),
(360, "Ctrl+l", "Toggle keys display (this screen)"),
(370, "Ctrl+t", "Toggle selection highlight colours"),
(380, "F7", "Previous in history"),
(390, "F8", "Next in history"),
(
400,
"F9",
"Suspend mouse capture and line numbers for system copy"
),
(410, "F10", "Resume mouse capture and line numbers"),
];