use crate::Mode;
use mathypad_core::core::MathypadCore;
use mathypad_core::expression::update_line_references_in_text;
use std::path::PathBuf;
use std::time::Instant;
#[derive(Clone, Debug)]
pub struct ResultAnimation {
pub start_time: Instant,
pub duration_ms: u64,
pub animation_type: AnimationType,
}
#[derive(Clone, Debug)]
pub enum AnimationType {
FadeIn,
CopyFlash,
}
impl ResultAnimation {
pub fn new_fade_in() -> Self {
Self {
start_time: Instant::now(),
duration_ms: 250, animation_type: AnimationType::FadeIn,
}
}
pub fn new_copy_flash() -> Self {
Self {
start_time: Instant::now(),
duration_ms: 150, animation_type: AnimationType::CopyFlash,
}
}
pub fn progress(&self) -> f32 {
let elapsed = self.start_time.elapsed().as_millis() as f32;
let progress = elapsed / self.duration_ms as f32;
progress.min(1.0)
}
pub fn is_complete(&self) -> bool {
self.progress() >= 1.0
}
pub fn opacity(&self) -> f32 {
match self.animation_type {
AnimationType::FadeIn => {
let progress = self.progress();
1.0 - (1.0 - progress).powi(3)
}
AnimationType::CopyFlash => {
let progress = self.progress();
1.0 - progress
}
}
}
}
pub struct App {
pub core: MathypadCore,
pub scroll_offset: usize,
pub mode: Mode,
pub result_animations: Vec<Option<ResultAnimation>>, pub file_path: Option<PathBuf>, pub has_unsaved_changes: bool, pub show_unsaved_dialog: bool, pub show_save_as_dialog: bool, pub save_as_input: String, pub save_as_and_quit: bool, pub separator_position: u16, pub is_dragging_separator: bool, pub is_hovering_separator: bool, pub copy_flash_animations: Vec<Option<ResultAnimation>>, pub copy_flash_is_result: Vec<bool>, pub last_click_time: Option<Instant>, pub last_click_position: Option<(u16, u16)>, pub show_welcome_dialog: bool, pub welcome_scroll_offset: usize, pub pending_normal_command: Option<char>, pub command_line: String, pub command_cursor: usize, }
impl Default for App {
fn default() -> App {
App {
core: MathypadCore::new(),
scroll_offset: 0,
mode: Mode::Insert, result_animations: vec![None], file_path: None, has_unsaved_changes: false, show_unsaved_dialog: false, show_save_as_dialog: false, save_as_input: String::new(), save_as_and_quit: false, separator_position: 80, is_dragging_separator: false, is_hovering_separator: false, copy_flash_animations: vec![None], copy_flash_is_result: vec![false], last_click_time: None, last_click_position: None, show_welcome_dialog: false, welcome_scroll_offset: 0, pending_normal_command: None, command_line: String::new(), command_cursor: 0, }
}
}
impl App {
#[cfg(test)]
pub fn test_scenario_line_splitting(&mut self) -> (String, String) {
self.core.text_lines = vec!["5".to_string(), "line1 + 1".to_string()];
self.core.results = vec![None, None];
self.core.cursor_line = 0;
self.core.cursor_col = 0;
let before = format!(
"Line 1: '{}', Line 2: '{}'",
self.core.text_lines[0], self.core.text_lines[1]
);
self.new_line();
let after = format!(
"Line 1: '{}', Line 2: '{}', Line 3: '{}'",
self.core.text_lines.first().unwrap_or(&"".to_string()),
self.core.text_lines.get(1).unwrap_or(&"".to_string()),
self.core.text_lines.get(2).unwrap_or(&"".to_string())
);
(before, after)
}
pub fn insert_char(&mut self, c: char) {
self.core.insert_char(c);
self.has_unsaved_changes = true;
}
pub fn delete_char(&mut self) {
if self.core.cursor_line < self.core.text_lines.len() {
if self.core.cursor_col > 0 {
let line = &mut self.core.text_lines[self.core.cursor_line];
let char_indices: Vec<_> = line.char_indices().collect();
if self.core.cursor_col > 0 && self.core.cursor_col <= char_indices.len() {
let char_to_delete_idx = self.core.cursor_col - 1;
let start_byte = char_indices[char_to_delete_idx].0;
let end_byte = if char_to_delete_idx + 1 < char_indices.len() {
char_indices[char_to_delete_idx + 1].0
} else {
line.len()
};
line.drain(start_byte..end_byte);
self.core.cursor_col -= 1;
self.update_result(self.core.cursor_line);
self.has_unsaved_changes = true;
}
} else if self.core.cursor_line > 0 {
let current_line = self.core.text_lines.remove(self.core.cursor_line);
self.core.results.remove(self.core.cursor_line);
if self.core.cursor_line < self.result_animations.len() {
self.result_animations.remove(self.core.cursor_line);
}
if self.core.cursor_line < self.copy_flash_animations.len() {
self.copy_flash_animations.remove(self.core.cursor_line);
}
if self.core.cursor_line < self.copy_flash_is_result.len() {
self.copy_flash_is_result.remove(self.core.cursor_line);
}
let prev_line_empty = self.core.text_lines[self.core.cursor_line - 1].is_empty();
if prev_line_empty && !current_line.is_empty() {
self.core.text_lines[self.core.cursor_line - 1] = current_line;
self.update_line_references_for_deletion(self.core.cursor_line - 1);
self.core.cursor_line -= 1;
self.core.cursor_col = 0;
} else {
self.update_line_references_for_deletion(self.core.cursor_line);
self.core.cursor_line -= 1;
self.core.cursor_col =
self.core.text_lines[self.core.cursor_line].chars().count();
self.core.text_lines[self.core.cursor_line].push_str(¤t_line);
}
self.update_result(self.core.cursor_line);
for i in (self.core.cursor_line + 1)..self.core.text_lines.len() {
self.update_result(i);
}
self.has_unsaved_changes = true;
}
}
}
pub fn delete_word(&mut self) {
if self.core.cursor_line < self.core.text_lines.len() && self.core.cursor_col > 0 {
let line = &self.core.text_lines[self.core.cursor_line];
let mut new_col = self.core.cursor_col;
while new_col > 0 && line.chars().nth(new_col - 1).unwrap_or(' ').is_whitespace() {
new_col -= 1;
}
while new_col > 0 {
let ch = line.chars().nth(new_col - 1).unwrap_or(' ');
if ch.is_alphanumeric() || ch == '_' {
new_col -= 1;
} else {
break;
}
}
if new_col == self.core.cursor_col {
while new_col > 0 {
let ch = line.chars().nth(new_col - 1).unwrap_or(' ');
if ch.is_whitespace() || ch.is_alphanumeric() || ch == '_' {
break;
}
new_col -= 1;
}
}
if new_col < self.core.cursor_col {
let line = &self.core.text_lines[self.core.cursor_line];
let char_indices: Vec<_> = line.char_indices().collect();
if !char_indices.is_empty() {
let start_byte = if new_col < char_indices.len() {
char_indices[new_col].0
} else {
line.len()
};
let end_byte = if self.core.cursor_col < char_indices.len() {
char_indices[self.core.cursor_col].0
} else {
line.len()
};
if start_byte < end_byte {
self.core.text_lines[self.core.cursor_line].drain(start_byte..end_byte);
}
}
self.core.cursor_col = new_col;
self.update_result(self.core.cursor_line);
self.has_unsaved_changes = true;
}
}
}
pub fn new_line(&mut self) {
if self.core.cursor_line < self.core.text_lines.len() {
let current_line = self.core.text_lines[self.core.cursor_line].clone();
let char_count = current_line.chars().count();
let safe_cursor_col = self.core.cursor_col.min(char_count);
let (left, right) = if safe_cursor_col == 0 {
("".to_string(), current_line)
} else if safe_cursor_col >= char_count {
(current_line, "".to_string())
} else {
let split_byte_idx = current_line
.char_indices()
.nth(safe_cursor_col)
.map(|(i, _)| i)
.unwrap_or(current_line.len());
let left = current_line[..split_byte_idx].to_string();
let right = current_line[split_byte_idx..].to_string();
(left, right)
};
let left_empty = left.trim().is_empty();
let right_empty = right.trim().is_empty();
self.core.text_lines[self.core.cursor_line] = left;
self.core
.text_lines
.insert(self.core.cursor_line + 1, right);
self.core.results.insert(self.core.cursor_line + 1, None);
if self.core.cursor_line + 1 < self.result_animations.len() {
self.result_animations
.insert(self.core.cursor_line + 1, None);
} else {
while self.result_animations.len() <= self.core.cursor_line + 1 {
self.result_animations.push(None);
}
}
if self.core.cursor_line + 1 < self.copy_flash_animations.len() {
self.copy_flash_animations
.insert(self.core.cursor_line + 1, None);
self.copy_flash_is_result
.insert(self.core.cursor_line + 1, false);
} else {
while self.copy_flash_animations.len() <= self.core.cursor_line + 1 {
self.copy_flash_animations.push(None);
self.copy_flash_is_result.push(false);
}
}
let insertion_point = self.core.cursor_line + 1;
if left_empty && !right_empty {
self.update_line_references_for_line_split_with_content_move(
self.core.cursor_line,
insertion_point,
);
} else {
self.update_line_references_for_standard_insertion(insertion_point);
}
self.core.cursor_line += 1;
self.core.cursor_col = 0;
self.update_result(self.core.cursor_line - 1); self.update_result(self.core.cursor_line);
for i in (self.core.cursor_line + 1)..self.core.text_lines.len() {
self.update_result(i);
}
self.has_unsaved_changes = true;
}
}
pub fn move_cursor_up(&mut self) {
if self.core.cursor_line > 0 {
self.core.cursor_line -= 1;
self.core.cursor_col = self
.core
.cursor_col
.min(self.core.text_lines[self.core.cursor_line].len());
}
}
pub fn move_cursor_down(&mut self) {
if self.core.cursor_line + 1 < self.core.text_lines.len() {
self.core.cursor_line += 1;
self.core.cursor_col = self
.core
.cursor_col
.min(self.core.text_lines[self.core.cursor_line].len());
}
}
pub fn move_cursor_left(&mut self) {
if self.core.cursor_col > 0 {
self.core.cursor_col -= 1;
}
}
pub fn move_cursor_right(&mut self) {
if self.core.cursor_line < self.core.text_lines.len() {
self.core.cursor_col =
(self.core.cursor_col + 1).min(self.core.text_lines[self.core.cursor_line].len());
}
}
pub fn delete_line(&mut self) {
if self.core.text_lines.len() > 1 {
self.update_line_references_for_deletion(self.core.cursor_line);
self.core.text_lines.remove(self.core.cursor_line);
self.core.results.remove(self.core.cursor_line);
if self.core.cursor_line < self.result_animations.len() {
self.result_animations.remove(self.core.cursor_line);
}
if self.core.cursor_line < self.copy_flash_animations.len() {
self.copy_flash_animations.remove(self.core.cursor_line);
self.copy_flash_is_result.remove(self.core.cursor_line);
}
if self.core.cursor_line >= self.core.text_lines.len() && self.core.cursor_line > 0 {
self.core.cursor_line -= 1;
}
self.core.cursor_col = 0;
for i in self.core.cursor_line..self.core.text_lines.len() {
self.update_result(i);
}
self.has_unsaved_changes = true;
} else if self.core.text_lines.len() == 1 {
self.core.text_lines[0].clear();
self.core.results[0] = None;
self.core.cursor_col = 0;
self.update_result(0);
self.has_unsaved_changes = true;
}
}
pub fn delete_char_at_cursor(&mut self) {
if self.core.cursor_line < self.core.text_lines.len() {
let line = &self.core.text_lines[self.core.cursor_line];
let char_count = line.chars().count();
if self.core.cursor_col < char_count {
let char_indices: Vec<_> = line.char_indices().collect();
if self.core.cursor_col < char_indices.len() {
let byte_start = char_indices[self.core.cursor_col].0;
let byte_end = if self.core.cursor_col + 1 < char_indices.len() {
char_indices[self.core.cursor_col + 1].0
} else {
line.len()
};
self.core.text_lines[self.core.cursor_line].drain(byte_start..byte_end);
let new_char_count =
self.core.text_lines[self.core.cursor_line].chars().count();
if self.core.cursor_col >= new_char_count && self.core.cursor_col > 0 {
self.core.cursor_col = new_char_count;
}
self.update_result(self.core.cursor_line);
self.has_unsaved_changes = true;
}
}
}
}
pub fn move_word_forward(&mut self) {
if self.core.cursor_line >= self.core.text_lines.len() {
return;
}
let line = &self.core.text_lines[self.core.cursor_line];
let chars: Vec<char> = line.chars().collect();
let mut new_col = self.core.cursor_col;
while new_col < chars.len() && (chars[new_col].is_alphanumeric() || chars[new_col] == '_') {
new_col += 1;
}
while new_col < chars.len() && !(chars[new_col].is_alphanumeric() || chars[new_col] == '_')
{
new_col += 1;
}
if new_col >= chars.len() && self.core.cursor_line + 1 < self.core.text_lines.len() {
self.core.cursor_line += 1;
self.core.cursor_col = 0;
} else {
self.core.cursor_col = new_col;
}
}
pub fn move_word_backward(&mut self) {
if self.core.cursor_line >= self.core.text_lines.len() {
return;
}
let line = &self.core.text_lines[self.core.cursor_line];
let chars: Vec<char> = line.chars().collect();
if self.core.cursor_col == 0 {
if self.core.cursor_line > 0 {
self.core.cursor_line -= 1;
self.core.cursor_col = self.core.text_lines[self.core.cursor_line].chars().count();
}
return;
}
let mut new_col = self.core.cursor_col;
new_col = new_col.saturating_sub(1);
while new_col > 0 && !(chars[new_col].is_alphanumeric() || chars[new_col] == '_') {
new_col -= 1;
}
while new_col > 0 && (chars[new_col - 1].is_alphanumeric() || chars[new_col - 1] == '_') {
new_col -= 1;
}
self.core.cursor_col = new_col;
}
pub fn move_word_forward_big(&mut self) {
if self.core.cursor_line >= self.core.text_lines.len() {
return;
}
let line = &self.core.text_lines[self.core.cursor_line];
let chars: Vec<char> = line.chars().collect();
let mut new_col = self.core.cursor_col;
while new_col < chars.len() && !chars[new_col].is_whitespace() {
new_col += 1;
}
while new_col < chars.len() && chars[new_col].is_whitespace() {
new_col += 1;
}
if new_col >= chars.len() && self.core.cursor_line + 1 < self.core.text_lines.len() {
self.core.cursor_line += 1;
self.core.cursor_col = 0;
} else {
self.core.cursor_col = new_col;
}
}
pub fn move_word_backward_big(&mut self) {
if self.core.cursor_line >= self.core.text_lines.len() {
return;
}
let line = &self.core.text_lines[self.core.cursor_line];
let chars: Vec<char> = line.chars().collect();
if self.core.cursor_col == 0 {
if self.core.cursor_line > 0 {
self.core.cursor_line -= 1;
self.core.cursor_col = self.core.text_lines[self.core.cursor_line].chars().count();
}
return;
}
let mut new_col = self.core.cursor_col;
new_col = new_col.saturating_sub(1);
while new_col > 0 && chars[new_col].is_whitespace() {
new_col -= 1;
}
while new_col > 0 && !chars[new_col - 1].is_whitespace() {
new_col -= 1;
}
self.core.cursor_col = new_col;
}
pub fn delete_word_forward(&mut self) {
if self.core.cursor_line >= self.core.text_lines.len() {
return;
}
let line = &self.core.text_lines[self.core.cursor_line];
let chars: Vec<char> = line.chars().collect();
let start_col = self.core.cursor_col;
let mut end_col = self.core.cursor_col;
while end_col < chars.len() && (chars[end_col].is_alphanumeric() || chars[end_col] == '_') {
end_col += 1;
}
while end_col < chars.len() && !(chars[end_col].is_alphanumeric() || chars[end_col] == '_')
{
end_col += 1;
}
if end_col > start_col {
let line = &self.core.text_lines[self.core.cursor_line];
let char_indices: Vec<_> = line.char_indices().collect();
if !char_indices.is_empty() {
let start_byte = if start_col < char_indices.len() {
char_indices[start_col].0
} else {
line.len()
};
let end_byte = if end_col < char_indices.len() {
char_indices[end_col].0
} else {
line.len()
};
if start_byte < end_byte {
self.core.text_lines[self.core.cursor_line].drain(start_byte..end_byte);
}
}
self.update_result(self.core.cursor_line);
self.has_unsaved_changes = true;
}
}
pub fn delete_word_backward(&mut self) {
if self.core.cursor_line >= self.core.text_lines.len() || self.core.cursor_col == 0 {
return;
}
let line = &self.core.text_lines[self.core.cursor_line];
let chars: Vec<char> = line.chars().collect();
let end_col = self.core.cursor_col;
let mut start_col = self.core.cursor_col;
start_col = start_col.saturating_sub(1);
while start_col > 0 && !(chars[start_col].is_alphanumeric() || chars[start_col] == '_') {
start_col -= 1;
}
while start_col > 0
&& (chars[start_col - 1].is_alphanumeric() || chars[start_col - 1] == '_')
{
start_col -= 1;
}
if end_col > start_col {
let line = &self.core.text_lines[self.core.cursor_line];
let char_indices: Vec<_> = line.char_indices().collect();
if !char_indices.is_empty() {
let start_byte = if start_col < char_indices.len() {
char_indices[start_col].0
} else {
line.len()
};
let end_byte = if end_col < char_indices.len() {
char_indices[end_col].0
} else {
line.len()
};
if start_byte < end_byte {
self.core.text_lines[self.core.cursor_line].drain(start_byte..end_byte);
}
}
self.core.cursor_col = start_col;
self.update_result(self.core.cursor_line);
self.has_unsaved_changes = true;
}
}
pub fn delete_word_forward_big(&mut self) {
if self.core.cursor_line >= self.core.text_lines.len() {
return;
}
let line = &self.core.text_lines[self.core.cursor_line];
let chars: Vec<char> = line.chars().collect();
let start_col = self.core.cursor_col;
let mut end_col = self.core.cursor_col;
while end_col < chars.len() && !chars[end_col].is_whitespace() {
end_col += 1;
}
while end_col < chars.len() && chars[end_col].is_whitespace() {
end_col += 1;
}
if end_col > start_col {
let line = &self.core.text_lines[self.core.cursor_line];
let char_indices: Vec<_> = line.char_indices().collect();
if !char_indices.is_empty() {
let start_byte = if start_col < char_indices.len() {
char_indices[start_col].0
} else {
line.len()
};
let end_byte = if end_col < char_indices.len() {
char_indices[end_col].0
} else {
line.len()
};
if start_byte < end_byte {
self.core.text_lines[self.core.cursor_line].drain(start_byte..end_byte);
}
}
self.update_result(self.core.cursor_line);
self.has_unsaved_changes = true;
}
}
pub fn delete_word_backward_big(&mut self) {
if self.core.cursor_line >= self.core.text_lines.len() || self.core.cursor_col == 0 {
return;
}
let line = &self.core.text_lines[self.core.cursor_line];
let chars: Vec<char> = line.chars().collect();
let end_col = self.core.cursor_col;
let mut start_col = self.core.cursor_col;
start_col = start_col.saturating_sub(1);
while start_col > 0 && chars[start_col].is_whitespace() {
start_col -= 1;
}
while start_col > 0 && !chars[start_col - 1].is_whitespace() {
start_col -= 1;
}
if end_col > start_col {
let line = &self.core.text_lines[self.core.cursor_line];
let char_indices: Vec<_> = line.char_indices().collect();
if !char_indices.is_empty() {
let start_byte = if start_col < char_indices.len() {
char_indices[start_col].0
} else {
line.len()
};
let end_byte = if end_col < char_indices.len() {
char_indices[end_col].0
} else {
line.len()
};
if start_byte < end_byte {
self.core.text_lines[self.core.cursor_line].drain(start_byte..end_byte);
}
}
self.core.cursor_col = start_col;
self.update_result(self.core.cursor_line);
self.has_unsaved_changes = true;
}
}
pub fn update_result(&mut self, line_index: usize) {
self.core.update_result(line_index);
if line_index < self.core.results.len() && self.core.results[line_index].is_some() {
self.start_result_animation(line_index);
}
}
fn update_line_references_for_deletion(&mut self, deleted_line: usize) {
for i in 0..self.core.text_lines.len() {
let updated_text =
update_line_references_in_text(&self.core.text_lines[i], deleted_line, -1);
if updated_text != self.core.text_lines[i] {
self.core.text_lines[i] = updated_text;
self.update_result(i);
}
}
}
fn update_line_references_for_standard_insertion(&mut self, insertion_point: usize) {
for i in 0..self.core.text_lines.len() {
let updated_text =
update_line_references_in_text(&self.core.text_lines[i], insertion_point, 1);
if updated_text != self.core.text_lines[i] {
self.core.text_lines[i] = updated_text;
self.update_result(i);
}
}
}
fn update_line_references_for_line_split_with_content_move(
&mut self,
content_from: usize,
insertion_point: usize,
) {
use crate::expression::extract_line_references;
for i in 0..self.core.text_lines.len() {
let references = extract_line_references(&self.core.text_lines[i]);
let mut updated_text = self.core.text_lines[i].clone();
for (start_pos, end_pos, line_num) in references.into_iter().rev() {
let new_line_num = if line_num == content_from {
insertion_point
} else if line_num >= insertion_point {
line_num + 1
} else {
line_num
};
if new_line_num != line_num {
let new_ref = format!("line{}", new_line_num + 1); updated_text.replace_range(start_pos..end_pos, &new_ref);
}
}
if updated_text != self.core.text_lines[i] {
self.core.text_lines[i] = updated_text;
self.update_result(i);
}
}
}
pub fn recalculate_all(&mut self) {
self.core.variables.clear();
for i in 0..self.core.text_lines.len() {
self.update_result(i);
}
}
fn start_result_animation(&mut self, line_index: usize) {
while self.result_animations.len() <= line_index {
self.result_animations.push(None);
}
if line_index < self.core.results.len() && self.core.results[line_index].is_some() {
self.result_animations[line_index] = Some(ResultAnimation::new_fade_in());
}
}
pub fn update_animations(&mut self) {
for animation in &mut self.result_animations {
if let Some(anim) = animation {
if anim.is_complete() {
*animation = None;
}
}
}
for animation in &mut self.copy_flash_animations {
if let Some(anim) = animation {
if anim.is_complete() {
*animation = None;
}
}
}
}
pub fn get_result_animation(&self, line_index: usize) -> Option<&ResultAnimation> {
self.result_animations.get(line_index)?.as_ref()
}
pub fn save(&mut self) -> Result<(), std::io::Error> {
if let Some(ref path) = self.file_path {
use std::fs;
let content = self.core.text_lines.join("\n");
fs::write(path, content)?;
self.has_unsaved_changes = false;
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"No file path set",
))
}
}
pub fn save_as(&mut self, path: PathBuf) -> Result<(), std::io::Error> {
use std::fs;
let content = self.core.text_lines.join("\n");
fs::write(&path, content)?;
self.file_path = Some(path);
self.has_unsaved_changes = false;
Ok(())
}
pub fn set_file_path(&mut self, path: Option<PathBuf>) {
self.file_path = path;
self.has_unsaved_changes = false;
}
pub fn show_save_as_dialog(&mut self, quit_after_save: bool) {
self.show_save_as_dialog = true;
self.save_as_and_quit = quit_after_save;
self.save_as_input = ".pad".to_string();
}
pub fn save_as_from_dialog(&mut self) -> Result<bool, std::io::Error> {
if !self.save_as_input.trim().is_empty() {
let path = PathBuf::from(self.save_as_input.trim());
self.save_as(path)?;
self.show_save_as_dialog = false;
let should_quit = self.save_as_and_quit;
self.save_as_and_quit = false;
Ok(should_quit)
} else {
Ok(false)
}
}
pub fn update_separator_position(&mut self, mouse_x: u16, terminal_width: u16) {
let percentage = ((mouse_x as f32 / terminal_width as f32) * 100.0) as u16;
self.separator_position = percentage.clamp(20, 80);
}
pub fn is_mouse_over_separator(&self, mouse_x: u16, terminal_width: u16) -> bool {
let separator_x = (self.separator_position as f32 / 100.0 * terminal_width as f32) as u16;
mouse_x.abs_diff(separator_x) <= 2
}
pub fn start_dragging_separator(&mut self) {
self.is_dragging_separator = true;
}
pub fn stop_dragging_separator(&mut self) {
self.is_dragging_separator = false;
}
pub fn set_separator_hover(&mut self, hovering: bool) {
self.is_hovering_separator = hovering;
}
pub fn copy_to_clipboard(
&mut self,
text: &str,
line_index: usize,
is_result: bool,
) -> Result<(), String> {
#[cfg(not(target_arch = "wasm32"))]
{
let mut clipboard = arboard::Clipboard::new()
.map_err(|e| format!("Failed to access clipboard: {}", e))?;
clipboard
.set_text(text)
.map_err(|e| format!("Failed to copy to clipboard: {}", e))?;
}
#[cfg(target_arch = "wasm32")]
{
let _ = text; }
self.start_copy_flash_animation(line_index, is_result);
Ok(())
}
fn start_copy_flash_animation(&mut self, line_index: usize, is_result: bool) {
while self.copy_flash_animations.len() <= line_index {
self.copy_flash_animations.push(None);
self.copy_flash_is_result.push(false);
}
self.copy_flash_animations[line_index] = Some(ResultAnimation::new_copy_flash());
while self.copy_flash_is_result.len() <= line_index {
self.copy_flash_is_result.push(false);
}
self.copy_flash_is_result[line_index] = is_result;
}
pub fn is_double_click(&mut self, mouse_x: u16, mouse_y: u16) -> bool {
let now = Instant::now();
let double_click_threshold = std::time::Duration::from_millis(500); let position_threshold = 2;
if let (Some(last_time), Some((last_x, last_y))) =
(self.last_click_time, self.last_click_position)
{
let time_diff = now.duration_since(last_time);
let position_diff = ((mouse_x as i32 - last_x as i32).abs()
+ (mouse_y as i32 - last_y as i32).abs()) as u16;
if time_diff <= double_click_threshold && position_diff <= position_threshold {
self.last_click_time = None;
self.last_click_position = None;
return true;
}
}
self.last_click_time = Some(now);
self.last_click_position = Some((mouse_x, mouse_y));
false
}
pub fn get_copy_flash_animation(&self, line_index: usize) -> Option<&ResultAnimation> {
self.copy_flash_animations.get(line_index)?.as_ref()
}
}
#[cfg(test)]
mod app_tests {
use super::*;
#[test]
fn test_line_splitting_with_line_references() {
let mut app = App::default();
let (before, after) = app.test_scenario_line_splitting();
println!("Before: {}", before);
println!("After: {}", after);
println!("App state after split:");
for (i, line) in app.core.text_lines.iter().enumerate() {
println!(" Line {}: '{}'", i + 1, line);
}
assert!(
app.core.text_lines[2].contains("line2"),
"Expected 'line1' to be updated to 'line2' but got: '{}'",
app.core.text_lines[2]
);
}
#[test]
fn test_line_splitting_at_beginning() {
let mut app = App::default();
app.core.text_lines = vec!["5".to_string(), "line1 + 1".to_string()];
app.core.results = vec![None, None];
app.core.cursor_line = 0;
app.core.cursor_col = 0;
app.new_line();
assert_eq!(app.core.text_lines[0], "");
assert_eq!(app.core.text_lines[1], "5");
assert!(
app.core.text_lines[2].contains("line2"),
"Expected 'line1' to be updated to 'line2' but got: '{}'",
app.core.text_lines[2]
);
}
#[test]
fn test_user_reported_scenario() {
let mut app = App::default();
app.core.text_lines = vec!["5".to_string(), "line1 + 1".to_string()];
app.core.results = vec![None, None];
app.update_result(0); app.update_result(1);
assert_eq!(app.core.results[0], Some("5".to_string()));
assert_eq!(app.core.results[1], Some("6".to_string()));
app.core.cursor_line = 0;
app.core.cursor_col = 0; app.new_line();
assert_eq!(app.core.text_lines[0], "");
assert_eq!(app.core.text_lines[1], "5");
assert!(
app.core.text_lines[2].contains("line2"),
"Expected line reference to be updated to 'line2', got: '{}'",
app.core.text_lines[2]
);
assert_eq!(
app.core.results[2],
Some("6".to_string()),
"Expected 'line2 + 1' to evaluate to 6 automatically, got: {:?}",
app.core.results[2]
);
}
#[test]
fn test_deletion_with_line_references() {
let mut app = App::default();
app.core.text_lines = vec!["".to_string(), "5".to_string(), "line2 + 1".to_string()];
app.core.results = vec![None, None, None];
app.update_result(0);
app.update_result(1);
app.update_result(2);
assert_eq!(app.core.results[1], Some("5".to_string()));
assert_eq!(app.core.results[2], Some("6".to_string()));
app.core.cursor_line = 1;
app.core.cursor_col = 0;
app.delete_char();
assert_eq!(app.core.text_lines.len(), 2);
assert_eq!(app.core.text_lines[0], "5");
assert!(
app.core.text_lines[1].contains("line1"),
"Expected 'line2' to be updated back to 'line1', got: '{}'",
app.core.text_lines[1]
);
assert_eq!(
app.core.results[1],
Some("6".to_string()),
"Expected 'line1 + 1' to evaluate to 6 after deletion, got: {:?}",
app.core.results[1]
);
}
#[test]
fn test_full_user_workflow_add_then_remove_lines() {
let mut app = App::default();
app.core.text_lines = vec!["5".to_string(), "line1 + 1".to_string()];
app.core.results = vec![None, None];
app.update_result(0);
app.update_result(1);
assert_eq!(app.core.results[0], Some("5".to_string()));
assert_eq!(app.core.results[1], Some("6".to_string()));
app.core.cursor_line = 0;
app.core.cursor_col = 0;
app.new_line();
assert_eq!(app.core.text_lines.len(), 3);
assert_eq!(app.core.text_lines[0], "");
assert_eq!(app.core.text_lines[1], "5");
assert!(app.core.text_lines[2].contains("line2"));
assert_eq!(app.core.results[2], Some("6".to_string()));
app.core.cursor_line = 1;
app.core.cursor_col = 0;
app.delete_char();
assert_eq!(app.core.text_lines.len(), 2);
assert_eq!(app.core.text_lines[0], "5");
assert!(app.core.text_lines[1].contains("line1"));
assert_eq!(
app.core.results[1],
Some("6".to_string()),
"Full workflow failed: expected line1 + 1 = 6 after add/remove cycle"
);
}
#[test]
fn test_separator_position_updates() {
let mut app = App::default();
assert_eq!(app.separator_position, 80);
app.update_separator_position(400, 1000); assert_eq!(app.separator_position, 40);
app.update_separator_position(100, 1000); assert_eq!(app.separator_position, 20);
app.update_separator_position(900, 1000); assert_eq!(app.separator_position, 80);
}
#[test]
fn test_mouse_over_separator_detection() {
let app = App::default(); let terminal_width = 1000;
let separator_x = 800;
assert!(app.is_mouse_over_separator(separator_x, terminal_width));
assert!(app.is_mouse_over_separator(separator_x - 2, terminal_width));
assert!(app.is_mouse_over_separator(separator_x + 2, terminal_width));
assert!(!app.is_mouse_over_separator(separator_x - 3, terminal_width));
assert!(!app.is_mouse_over_separator(separator_x + 3, terminal_width));
}
#[test]
fn test_separator_dragging_state() {
let mut app = App::default();
assert!(!app.is_dragging_separator);
app.start_dragging_separator();
assert!(app.is_dragging_separator);
app.stop_dragging_separator();
assert!(!app.is_dragging_separator);
}
#[test]
fn test_separator_hover_state() {
let mut app = App::default();
assert!(!app.is_hovering_separator);
app.set_separator_hover(true);
assert!(app.is_hovering_separator);
app.set_separator_hover(false);
assert!(!app.is_hovering_separator);
}
#[test]
fn test_double_click_detection() {
let mut app = App::default();
assert!(!app.is_double_click(100, 100));
assert!(app.is_double_click(100, 100));
assert!(!app.is_double_click(100, 100));
}
#[test]
fn test_double_click_position_threshold() {
let mut app = App::default();
assert!(!app.is_double_click(100, 100));
assert!(app.is_double_click(101, 101));
app.last_click_time = None;
app.last_click_position = None;
assert!(!app.is_double_click(100, 100));
assert!(!app.is_double_click(110, 110));
}
#[test]
fn test_copy_flash_animation() {
let mut app = App::default();
app.core.text_lines = vec!["test".to_string(), "test2".to_string()];
app.copy_flash_animations = vec![None, None];
app.start_copy_flash_animation(0, false);
assert!(app.get_copy_flash_animation(0).is_some());
assert!(app.get_copy_flash_animation(1).is_none());
let animation = app.get_copy_flash_animation(0).unwrap();
assert!(matches!(
animation.animation_type,
crate::app::AnimationType::CopyFlash
));
}
#[test]
fn test_delete_line() {
let mut app = App::default();
app.core.text_lines = vec![
"first".to_string(),
"second".to_string(),
"third".to_string(),
];
app.core.results = vec![None, None, None];
app.result_animations = vec![None, None, None];
app.copy_flash_animations = vec![None, None, None];
app.copy_flash_is_result = vec![false, false, false];
app.core.cursor_line = 1;
app.delete_line();
assert_eq!(app.core.text_lines, vec!["first", "third"]);
assert_eq!(app.core.cursor_line, 1);
assert_eq!(app.core.cursor_col, 0);
app.delete_line();
assert_eq!(app.core.text_lines, vec!["first"]);
assert_eq!(app.core.cursor_line, 0);
app.delete_line();
assert_eq!(app.core.text_lines, vec![""]);
assert_eq!(app.core.cursor_line, 0);
}
#[test]
fn test_delete_char_at_cursor() {
let mut app = App::default();
app.core.text_lines = vec!["hello world".to_string()];
app.core.results = vec![None];
app.core.cursor_line = 0;
app.core.cursor_col = 6;
app.delete_char_at_cursor();
assert_eq!(app.core.text_lines[0], "hello orld");
assert_eq!(app.core.cursor_col, 6);
app.core.cursor_col = app.core.text_lines[0].len();
app.delete_char_at_cursor();
assert_eq!(app.core.text_lines[0], "hello orld");
}
#[test]
fn test_word_movement_forward() {
let mut app = App::default();
app.core.text_lines = vec!["hello world test_var 123".to_string()];
app.core.cursor_line = 0;
app.core.cursor_col = 0;
app.move_word_forward();
assert_eq!(app.core.cursor_col, 6);
app.move_word_forward();
assert_eq!(app.core.cursor_col, 12);
app.move_word_forward();
assert_eq!(app.core.cursor_col, 21);
app.move_word_forward();
assert_eq!(app.core.cursor_col, 24); }
#[test]
fn test_word_movement_backward() {
let mut app = App::default();
app.core.text_lines = vec!["hello world test_var".to_string()];
app.core.cursor_line = 0;
app.core.cursor_col = 20;
app.move_word_backward();
assert_eq!(app.core.cursor_col, 12);
app.move_word_backward();
assert_eq!(app.core.cursor_col, 6);
app.move_word_backward();
assert_eq!(app.core.cursor_col, 0);
}
#[test]
fn test_word_movement_big() {
let mut app = App::default();
app.core.text_lines = vec!["hello-world test::func()".to_string()];
app.core.cursor_line = 0;
app.core.cursor_col = 0;
app.move_word_forward_big();
assert_eq!(app.core.cursor_col, 12);
app.core.cursor_col = 24;
app.move_word_backward_big();
assert_eq!(app.core.cursor_col, 12);
app.move_word_backward_big();
assert_eq!(app.core.cursor_col, 0);
}
#[test]
fn test_delete_word_forward() {
let mut app = App::default();
app.core.text_lines = vec!["hello world test".to_string()];
app.core.results = vec![None];
app.core.cursor_line = 0;
app.core.cursor_col = 0;
app.delete_word_forward();
assert_eq!(app.core.text_lines[0], "world test");
assert_eq!(app.core.cursor_col, 0);
app.core.cursor_col = 2; app.delete_word_forward();
assert_eq!(app.core.text_lines[0], "wotest");
}
#[test]
fn test_delete_word_backward() {
let mut app = App::default();
app.core.text_lines = vec!["hello world test".to_string()];
app.core.results = vec![None];
app.core.cursor_line = 0;
app.core.cursor_col = 11;
app.delete_word_backward();
assert_eq!(app.core.text_lines[0], "hello test");
assert_eq!(app.core.cursor_col, 6);
}
#[test]
fn test_delete_word_forward_big() {
let mut app = App::default();
app.core.text_lines = vec!["hello-world test::func()".to_string()];
app.core.results = vec![None];
app.core.cursor_line = 0;
app.core.cursor_col = 0;
app.delete_word_forward_big();
assert_eq!(app.core.text_lines[0], "test::func()");
assert_eq!(app.core.cursor_col, 0);
}
#[test]
fn test_pending_normal_command() {
let mut app = App {
pending_normal_command: Some('d'),
..Default::default()
};
assert_eq!(app.pending_normal_command, Some('d'));
app.pending_normal_command = None;
assert_eq!(app.pending_normal_command, None);
}
#[test]
fn test_command_mode_initialization() {
let mut app = App::default();
assert_eq!(app.mode, Mode::Insert);
assert_eq!(app.command_line, "");
assert_eq!(app.command_cursor, 0);
app.mode = Mode::Command;
app.command_line = ":".to_string();
app.command_cursor = 1;
assert_eq!(app.mode, Mode::Command);
assert_eq!(app.command_line, ":");
assert_eq!(app.command_cursor, 1);
}
#[test]
fn test_command_line_editing() {
let mut app = App {
mode: Mode::Command,
command_line: ":w".to_string(),
command_cursor: 2,
..Default::default()
};
assert_eq!(app.command_cursor, 2);
app.command_line = ":wq".to_string();
app.command_cursor = 3;
assert_eq!(app.command_line, ":wq");
assert_eq!(app.command_cursor, 3);
}
#[test]
fn test_command_mode_toggle() {
let mut app = App {
mode: Mode::Normal,
..Default::default()
};
assert_eq!(app.mode, Mode::Normal);
app.mode = Mode::Command;
assert_eq!(app.mode, Mode::Command);
app.mode = Mode::Normal;
assert_eq!(app.mode, Mode::Normal);
}
#[test]
fn test_delete_char_utf8() {
let mut app = App::default();
app.insert_char('€');
assert_eq!(app.core.cursor_col, 1);
app.insert_char(' ');
assert_eq!(app.core.cursor_col, 2);
app.insert_char('5');
assert_eq!(app.core.cursor_col, 3);
assert_eq!(app.core.text_lines[0], "€ 5");
app.delete_char();
assert_eq!(app.core.text_lines[0], "€ ");
assert_eq!(app.core.cursor_col, 2);
app.delete_char();
assert_eq!(app.core.text_lines[0], "€");
assert_eq!(app.core.cursor_col, 1);
app.delete_char();
assert_eq!(app.core.text_lines[0], "");
assert_eq!(app.core.cursor_col, 0);
app.insert_char('🚀');
app.insert_char('€');
app.insert_char('¥');
assert_eq!(app.core.text_lines[0], "🚀€¥");
assert_eq!(app.core.cursor_col, 3);
app.delete_char();
assert_eq!(app.core.text_lines[0], "🚀€");
assert_eq!(app.core.cursor_col, 2);
app.delete_char();
assert_eq!(app.core.text_lines[0], "🚀");
assert_eq!(app.core.cursor_col, 1);
}
}