diff --git a/src/app/config.rs b/src/app/config.rs index 7a21592..3f3655c 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -6,6 +6,7 @@ use super::theme::Theme; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { pub auto_hide_toolbar: bool, + pub auto_hide_tab_bar: bool, pub show_line_numbers: bool, pub word_wrap: bool, pub theme: Theme, @@ -19,6 +20,7 @@ impl Default for Config { fn default() -> Self { Self { auto_hide_toolbar: false, + auto_hide_tab_bar: false, show_line_numbers: false, word_wrap: true, theme: Theme::default(), @@ -52,7 +54,7 @@ impl Config { if !config_path.exists() { let default_config = Self::default(); if let Err(e) = default_config.save() { - eprintln!("Failed to create default config file: {}", e); + eprintln!("Failed to create default config file: {e}"); } return default_config; } diff --git a/src/app/state/app_impl.rs b/src/app/state/app_impl.rs index 20d9f55..61f1b57 100644 --- a/src/app/state/app_impl.rs +++ b/src/app/state/app_impl.rs @@ -1,11 +1,11 @@ +use crate::ui::central_panel::central_panel; +use crate::ui::menu_bar::menu_bar; +use crate::ui::tab_bar::tab_bar; +use crate::ui::about_window::about_window; +use crate::ui::shortcuts_window::shortcuts_window; +use crate::ui::preferences_window::preferences_window; +use crate::ui::find_window::find_window; use crate::app::shortcuts; -use crate::ui::{ - about_window::about_window, central_panel::central_panel, find_window::find_window, - menu_bar::menu_bar, preferences_window::preferences_window, shortcuts_window::shortcuts_window, - tab_bar::tab_bar, -}; -use eframe::egui; - use super::editor::TextEditor; impl eframe::App for TextEditor { @@ -24,77 +24,8 @@ impl eframe::App for TextEditor { menu_bar(self, ctx); - // if self.tabs.len() > 1 { - tab_bar(self, ctx); - // } - - // Extract data needed for calculations to avoid borrow conflicts - let (content_changed, layout_changed, needs_processing) = if let Some(active_tab) = self.get_active_tab() { - let content_changed = active_tab.last_content_hash != crate::app::tab::compute_content_hash(&active_tab.content, &mut std::hash::DefaultHasher::new()); - let layout_changed = self.needs_width_calculation(ctx); - (content_changed, layout_changed, true) - } else { - (false, false, false) - }; - - if needs_processing { - // Only recalculate width when layout parameters change, not on every keystroke - if layout_changed { - let width = if self.word_wrap { - // For word wrap, width only depends on layout parameters - let total_width = ctx.available_rect().width(); - if self.show_line_numbers { - let line_count = if let Some(tab) = self.get_active_tab() { - tab.content.lines().count().max(1) - } else { - 1 - }; - let line_count_digits = line_count.to_string().len(); - let estimated_char_width = self.font_size * 0.6; - let base_line_number_width = line_count_digits as f32 * estimated_char_width; - let line_number_width = if self.line_side { - base_line_number_width + 20.0 - } else { - base_line_number_width + 8.0 - }; - (total_width - line_number_width - 10.0).max(100.0) - } else { - total_width - } - } else { - // For non-word wrap, use a generous fixed width to avoid constant recalculation - // This prevents cursor jumping while still allowing horizontal scrolling - let base_width = ctx.available_rect().width(); - if self.show_line_numbers { - let estimated_char_width = self.font_size * 0.6; - let line_number_width = if self.line_side { 60.0 } else { 40.0 }; - (base_width - line_number_width - 10.0).max(100.0) - } else { - base_width - } - }; - - self.update_width_calculation_state(ctx, width); - } - - // Process text changes using stable cached width - if content_changed { - if let Some(active_tab) = self.get_active_tab() { - let content = active_tab.content.clone(); - let word_wrap = self.word_wrap; - let cached_width = self.get_cached_width(); - let available_width = cached_width.unwrap_or_else(|| { - // Initialize with a reasonable default if no cache exists - if word_wrap { - ctx.available_rect().width() - } else { - ctx.available_rect().width() - } - }); - - self.process_text_for_rendering(&content, available_width); - } - } + if !self.auto_hide_tab_bar { + tab_bar(self, ctx); } central_panel(self, ctx); @@ -117,6 +48,5 @@ impl eframe::App for TextEditor { // Update the previous find state for next frame self.prev_show_find = self.show_find; - } } diff --git a/src/app/state/config.rs b/src/app/state/config.rs index 5e548c0..58f6ca4 100644 --- a/src/app/state/config.rs +++ b/src/app/state/config.rs @@ -19,6 +19,7 @@ impl TextEditor { show_line_numbers: config.show_line_numbers, word_wrap: config.word_wrap, auto_hide_toolbar: config.auto_hide_toolbar, + auto_hide_tab_bar: config.auto_hide_tab_bar, theme: config.theme, line_side: config.line_side, font_family: config.font_family, @@ -35,17 +36,16 @@ impl TextEditor { current_match_index: None, case_sensitive_search: false, prev_show_find: false, - // Width calculation cache and state tracking - cached_width: None, - last_word_wrap: config.word_wrap, - last_show_line_numbers: config.show_line_numbers, - last_font_size: config.font_size, - last_line_side: config.line_side, - last_viewport_width: 0.0, // vim_mode: config.vim_mode, - + // Cursor tracking for smart scrolling previous_cursor_position: None, + + // Track previous content for incremental processing + previous_content: String::new(), + previous_cursor_char_index: None, + current_cursor_line: 0, + previous_cursor_line: 0, } } @@ -72,8 +72,6 @@ impl TextEditor { editor.apply_font_settings(&cc.egui_ctx); - editor.start_text_processing_thread(); - editor } @@ -81,6 +79,7 @@ impl TextEditor { Config { auto_hide_toolbar: self.auto_hide_toolbar, show_line_numbers: self.show_line_numbers, + auto_hide_tab_bar: self.auto_hide_tab_bar, word_wrap: self.word_wrap, theme: self.theme, line_side: self.line_side, @@ -93,7 +92,7 @@ impl TextEditor { pub fn save_config(&self) { let config = self.get_config(); if let Err(e) = config.save() { - eprintln!("Failed to save configuration: {}", e); + eprintln!("Failed to save configuration: {e}"); } } } diff --git a/src/app/state/default.rs b/src/app/state/default.rs index 9708f37..e4cd95c 100644 --- a/src/app/state/default.rs +++ b/src/app/state/default.rs @@ -19,6 +19,7 @@ impl Default for TextEditor { show_line_numbers: false, word_wrap: true, auto_hide_toolbar: false, + auto_hide_tab_bar: false, theme: Theme::default(), line_side: false, font_family: "Proportional".to_string(), @@ -36,17 +37,15 @@ impl Default for TextEditor { current_match_index: None, case_sensitive_search: false, prev_show_find: false, - // Width calculation cache and state tracking - cached_width: None, - last_word_wrap: true, - last_show_line_numbers: false, - last_font_size: 14.0, - last_line_side: false, - last_viewport_width: 0.0, - // vim_mode: false, // Cursor tracking for smart scrolling previous_cursor_position: None, + + // Track previous content for incremental processing + previous_content: String::new(), + previous_cursor_char_index: None, + current_cursor_line: 0, + previous_cursor_line: 0, } } } diff --git a/src/app/state/editor.rs b/src/app/state/editor.rs index acba608..9b136a2 100644 --- a/src/app/state/editor.rs +++ b/src/app/state/editor.rs @@ -13,18 +13,18 @@ pub enum UnsavedAction { #[derive(Clone)] pub struct TextProcessingResult { pub line_count: usize, - pub visual_line_mapping: Vec>, - pub max_line_length: f32, - pub _processed_content: String, + pub longest_line_index: usize, // Which line is the longest (0-based) + pub longest_line_length: usize, // Character count of the longest line + pub longest_line_pixel_width: f32, // Actual pixel width of the longest line } impl Default for TextProcessingResult { fn default() -> Self { Self { line_count: 1, - visual_line_mapping: vec![Some(1)], - max_line_length: 0.0, - _processed_content: String::new(), + longest_line_index: 0, + longest_line_length: 0, + longest_line_pixel_width: 0.0, } } } @@ -44,6 +44,7 @@ pub struct TextEditor { pub(crate) show_line_numbers: bool, pub(crate) word_wrap: bool, pub(crate) auto_hide_toolbar: bool, + pub(crate) auto_hide_tab_bar: bool, pub(crate) theme: Theme, pub(crate) line_side: bool, pub(crate) font_family: String, @@ -61,15 +62,12 @@ pub struct TextEditor { pub(crate) case_sensitive_search: bool, pub(crate) prev_show_find: bool, // Track previous state to detect transitions - // Width calculation cache and state tracking - pub(crate) cached_width: Option, - pub(crate) last_word_wrap: bool, - pub(crate) last_show_line_numbers: bool, - pub(crate) last_font_size: f32, - pub(crate) last_line_side: bool, - pub(crate) last_viewport_width: f32, - // pub(crate) vim_mode: bool, - // Cursor tracking for smart scrolling pub(crate) previous_cursor_position: Option, + + // Track previous content for incremental processing + pub(crate) previous_content: String, + pub(crate) previous_cursor_char_index: Option, + pub(crate) current_cursor_line: usize, // Track current line number incrementally + pub(crate) previous_cursor_line: usize, // Track previous line for comparison } diff --git a/src/app/state/lifecycle.rs b/src/app/state/lifecycle.rs index 17da8a2..95f597f 100644 --- a/src/app/state/lifecycle.rs +++ b/src/app/state/lifecycle.rs @@ -79,7 +79,7 @@ impl TextEditor { ui.add_space(8.0); for file in &files_to_list { - ui.label(egui::RichText::new(format!("• {}", file)).size(18.0).weak()); + ui.label(egui::RichText::new(format!("• {file}")).size(18.0).weak()); } ui.add_space(12.0); diff --git a/src/app/state/processing.rs b/src/app/state/processing.rs index 5aea877..3899e5d 100644 --- a/src/app/state/processing.rs +++ b/src/app/state/processing.rs @@ -1,63 +1,282 @@ -use std::sync::Arc; -use std::thread; - use super::editor::{TextEditor, TextProcessingResult}; +use eframe::egui; impl TextEditor { - pub fn start_text_processing_thread(&mut self) { - let _processing_result = Arc::clone(&self.text_processing_result); - - let handle = thread::Builder::new() - .name("TextProcessor".to_string()) - .spawn(move || { - // Set thread priority to high (platform-specific) - #[cfg(target_os = "linux")] - { - unsafe { - let thread_id = libc::pthread_self(); - let mut param: libc::sched_param = std::mem::zeroed(); - param.sched_priority = 50; - let _ = libc::pthread_setschedparam(thread_id, libc::SCHED_OTHER, ¶m); - } - } + /// Process text content and find the longest line (only used for initial scan) + pub fn process_text_for_rendering(&mut self, content: &str, ui: &egui::Ui) { + let lines: Vec<&str> = content.lines().collect(); + let line_count = lines.len().max(1); + + if lines.is_empty() { + self.update_processing_result(TextProcessingResult { + line_count: 1, + longest_line_index: 0, + longest_line_length: 0, + longest_line_pixel_width: 0.0, }); - - match handle { - Ok(h) => self.processing_thread_handle = Some(h), - Err(e) => eprintln!("Failed to start text processing thread: {}", e), + return; } - } - pub fn process_text_for_rendering( - &mut self, - content: &str, - available_width: f32, - ) { - let line_count = content.lines().count().max(1); + // Find the longest line by character count first (fast) + let mut longest_line_index = 0; + let mut longest_line_length = 0; + + for (index, line) in lines.iter().enumerate() { + let char_count = line.chars().count(); + if char_count > longest_line_length { + longest_line_length = char_count; + longest_line_index = index; + } + } - let visual_line_mapping = if self.word_wrap { - // For now, simplified mapping - this could be moved to background thread - (1..=line_count).map(Some).collect() + // Calculate pixel width for the longest line + let font_id = self.get_font_id(); + let longest_line_pixel_width = if longest_line_length > 0 { + let longest_line_text = lines[longest_line_index]; + ui.fonts(|fonts| { + fonts.layout( + longest_line_text.to_string(), + font_id, + egui::Color32::WHITE, + f32::INFINITY, + ).size().x + }) } else { - (1..=line_count).map(Some).collect() + 0.0 }; let result = TextProcessingResult { line_count, - visual_line_mapping, - max_line_length: available_width, - _processed_content: content.to_string(), + longest_line_index, + longest_line_length, + longest_line_pixel_width, }; - if let Ok(mut processing_result) = self.text_processing_result.lock() { - *processing_result = result; + self.update_processing_result(result); + } + + /// Efficiently detect and process line changes without full content iteration + pub fn process_incremental_change(&mut self, old_content: &str, new_content: &str, + old_cursor_pos: usize, new_cursor_pos: usize, ui: &egui::Ui) { + // Calculate cursor line change incrementally + let line_change = self.calculate_cursor_line_change(old_content, new_content, old_cursor_pos, new_cursor_pos); + + // Update current cursor line + self.current_cursor_line = (self.current_cursor_line as isize + line_change) as usize; + + // Detect the type of change and handle appropriately + if old_content.len() == new_content.len() { + // Same length - likely a character replacement + self.handle_character_replacement(old_content, new_content, old_cursor_pos, new_cursor_pos, ui); + } else if new_content.len() > old_content.len() { + // Content added + self.handle_content_addition(old_content, new_content, old_cursor_pos, new_cursor_pos, ui); + } else { + // Content removed + self.handle_content_removal(old_content, new_content, old_cursor_pos, new_cursor_pos, ui); + } + + self.previous_cursor_line = self.current_cursor_line; + } + + /// Calculate the change in cursor line without full iteration + fn calculate_cursor_line_change(&self, old_content: &str, new_content: &str, + old_cursor_pos: usize, new_cursor_pos: usize) -> isize { + // Count newlines up to the cursor position in both contents + let old_newlines = old_content[..old_cursor_pos.min(old_content.len())] + .bytes() + .filter(|&b| b == b'\n') + .count(); + + let new_newlines = new_content[..new_cursor_pos.min(new_content.len())] + .bytes() + .filter(|&b| b == b'\n') + .count(); + + new_newlines as isize - old_newlines as isize + } + + /// Handle character replacement (same length change) + fn handle_character_replacement(&mut self, old_content: &str, new_content: &str, + old_cursor_pos: usize, new_cursor_pos: usize, ui: &egui::Ui) { + // Extract the current line from new content + let current_line = self.extract_current_line(new_content, new_cursor_pos); + let current_line_length = current_line.chars().count(); + + self.update_line_if_longer(self.current_cursor_line, ¤t_line, current_line_length, ui); + } + + /// Handle content addition + fn handle_content_addition(&mut self, old_content: &str, new_content: &str, + old_cursor_pos: usize, new_cursor_pos: usize, ui: &egui::Ui) { + // Find the common prefix and suffix to identify the added text + let min_len = old_content.len().min(new_content.len()); + let mut common_prefix = 0; + let mut common_suffix = 0; + + // Find common prefix + for i in 0..min_len { + if old_content.as_bytes()[i] == new_content.as_bytes()[i] { + common_prefix += 1; + } else { + break; + } + } + + // Find common suffix + for i in 0..min_len - common_prefix { + let old_idx = old_content.len() - 1 - i; + let new_idx = new_content.len() - 1 - i; + if old_content.as_bytes()[old_idx] == new_content.as_bytes()[new_idx] { + common_suffix += 1; + } else { + break; + } + } + + // Extract the added text + let added_start = common_prefix; + let added_end = new_content.len() - common_suffix; + let added_text = &new_content[added_start..added_end]; + let newlines_added = added_text.bytes().filter(|&b| b == b'\n').count(); + + if newlines_added > 0 { + // Lines were added, update line count + let mut current_result = self.get_text_processing_result(); + current_result.line_count += newlines_added; + self.update_processing_result(current_result); + } + + // Check if the current line is now longer + let current_line = self.extract_current_line(new_content, new_cursor_pos); + let current_line_length = current_line.chars().count(); + + self.update_line_if_longer(self.current_cursor_line, ¤t_line, current_line_length, ui); + } + + /// Handle content removal + fn handle_content_removal(&mut self, old_content: &str, new_content: &str, + old_cursor_pos: usize, new_cursor_pos: usize, ui: &egui::Ui) { + // Find the common prefix and suffix to identify the removed text + let min_len = old_content.len().min(new_content.len()); + let mut common_prefix = 0; + let mut common_suffix = 0; + + // Find common prefix + for i in 0..min_len { + if old_content.as_bytes()[i] == new_content.as_bytes()[i] { + common_prefix += 1; + } else { + break; + } + } + + // Find common suffix + for i in 0..min_len - common_prefix { + let old_idx = old_content.len() - 1 - i; + let new_idx = new_content.len() - 1 - i; + if old_content.as_bytes()[old_idx] == new_content.as_bytes()[new_idx] { + common_suffix += 1; + } else { + break; + } + } + + // Extract the removed text + let removed_start = common_prefix; + let removed_end = old_content.len() - common_suffix; + let removed_text = &old_content[removed_start..removed_end]; + let newlines_removed = removed_text.bytes().filter(|&b| b == b'\n').count(); + + if newlines_removed > 0 { + // Lines were removed, update line count + let mut current_result = self.get_text_processing_result(); + current_result.line_count = current_result.line_count.saturating_sub(newlines_removed); + + // If we removed the longest line, we need to rescan (but only if necessary) + if self.current_cursor_line <= current_result.longest_line_index { + // The longest line might have been affected, but let's be conservative + // and only rescan if we're sure it was the longest line + if self.current_cursor_line == current_result.longest_line_index { + self.process_text_for_rendering(new_content, ui); + return; + } + } + + self.update_processing_result(current_result); + } + + // Check if the current line changed + let current_line = self.extract_current_line(new_content, new_cursor_pos); + let current_line_length = current_line.chars().count(); + + // If this was the longest line and it got shorter, we might need to rescan + let current_result = self.get_text_processing_result(); + if self.current_cursor_line == current_result.longest_line_index && + current_line_length < current_result.longest_line_length { + self.process_text_for_rendering(new_content, ui); + } else { + self.update_line_if_longer(self.current_cursor_line, ¤t_line, current_line_length, ui); } } + /// Extract the current line efficiently without full content scan + fn extract_current_line(&self, content: &str, cursor_pos: usize) -> String { + let bytes = content.as_bytes(); + + // Find line start (search backwards from cursor) + let mut line_start = cursor_pos; + while line_start > 0 && bytes[line_start - 1] != b'\n' { + line_start -= 1; + } + + // Find line end (search forwards from cursor) + let mut line_end = cursor_pos; + while line_end < bytes.len() && bytes[line_end] != b'\n' { + line_end += 1; + } + + content[line_start..line_end].to_string() + } + + /// Update longest line info if the current line is longer + fn update_line_if_longer(&mut self, line_index: usize, line_content: &str, line_length: usize, ui: &egui::Ui) { + let current_result = self.get_text_processing_result(); + + if line_length > current_result.longest_line_length { + let font_id = self.get_font_id(); + let pixel_width = ui.fonts(|fonts| { + fonts.layout( + line_content.to_string(), + font_id, + egui::Color32::WHITE, + f32::INFINITY, + ).size().x + }); + + let result = TextProcessingResult { + line_count: current_result.line_count, + longest_line_index: line_index, + longest_line_length: line_length, + longest_line_pixel_width: pixel_width, + }; + + self.update_processing_result(result); + } + } + + /// Get the current text processing result pub fn get_text_processing_result(&self) -> TextProcessingResult { self.text_processing_result .lock() .map(|result| result.clone()) .unwrap_or_default() } + + /// Update the processing result atomically + fn update_processing_result(&self, result: TextProcessingResult) { + if let Ok(mut processing_result) = self.text_processing_result.lock() { + *processing_result = result; + } + } } diff --git a/src/app/state/ui.rs b/src/app/state/ui.rs index 7d7a5af..a4951e7 100644 --- a/src/app/state/ui.rs +++ b/src/app/state/ui.rs @@ -12,14 +12,19 @@ impl TextEditor { pub fn get_title(&self) -> String { if let Some(tab) = self.get_active_tab() { let modified_indicator = if tab.is_modified { "*" } else { "" }; - format!("{}{} - C-Text", tab.title, modified_indicator) + format!( + "{}{} - {}", + tab.title, + modified_indicator, + env!("CARGO_PKG_NAME") + ) } else { - "C-Text".to_string() + format!("{} - {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")) } } /// Get the configured font ID based on the editor's font settings - fn get_font_id(&self) -> egui::FontId { + pub fn get_font_id(&self) -> egui::FontId { let font_family = match self.font_family.as_str() { "Monospace" => egui::FontFamily::Monospace, _ => egui::FontFamily::Proportional, @@ -51,7 +56,7 @@ impl TextEditor { /// Calculates the available width for the text editor, accounting for line numbers and separator pub fn calculate_editor_dimensions(&self, ui: &egui::Ui) -> EditorDimensions { let total_available_width = ui.available_width(); - + if !self.show_line_numbers { return EditorDimensions { text_width: total_available_width, @@ -63,7 +68,7 @@ impl TextEditor { // Get line count from processing result let processing_result = self.get_text_processing_result(); let line_count = processing_result.line_count; - + // Calculate base line number width let font_id = self.get_font_id(); let line_count_digits = line_count.to_string().len(); @@ -77,14 +82,14 @@ impl TextEditor { // Add padding based on line_side setting let line_number_width = if self.line_side { - base_line_number_width + 20.0 // Extra padding when line numbers are on the side + base_line_number_width + 20.0 } else { - base_line_number_width + 8.0 // Minimal padding when line numbers are normal + base_line_number_width + 8.0 }; // Separator space (7.0 for separator + 3.0 spacing = 10.0 total) let separator_width = 10.0; - + let total_reserved_width = line_number_width + separator_width; let text_width = (total_available_width - total_reserved_width).max(100.0); // Minimum 100px for text @@ -95,68 +100,19 @@ impl TextEditor { } } - /// Calculate the available width for non-word-wrapped content based on content analysis + /// Calculate the available width for non-word-wrapped content based on processed text data pub fn calculate_content_based_width(&self, ui: &egui::Ui) -> f32 { - if let Some(active_tab) = self.get_active_tab() { - let content = &active_tab.content; - - if content.is_empty() { - return self.calculate_editor_dimensions(ui).text_width; - } + let processing_result = self.get_text_processing_result(); - // Find the longest line - let longest_line = content - .lines() - .max_by_key(|line| line.chars().count()) - .unwrap_or(""); - - if longest_line.is_empty() { - return self.calculate_editor_dimensions(ui).text_width; - } - - // Calculate the width needed for the longest line - let font_id = self.get_font_id(); - let longest_line_width = ui.fonts(|fonts| { - fonts.layout( - longest_line.to_string(), - font_id, - egui::Color32::WHITE, - f32::INFINITY, - ).size().x - }) + 20.0; // Add some padding - - // Return the larger of the calculated width or minimum available width - let dimensions = self.calculate_editor_dimensions(ui); - longest_line_width.max(dimensions.text_width) - } else { - self.calculate_editor_dimensions(ui).text_width + if processing_result.longest_line_length == 0 { + return self.calculate_editor_dimensions(ui).text_width; } - } - /// Check if width calculation needs to be performed based on parameter changes - pub fn needs_width_calculation(&self, ctx: &egui::Context) -> bool { - let current_viewport_width = ctx.available_rect().width(); - - self.cached_width.is_none() || - self.word_wrap != self.last_word_wrap || - self.show_line_numbers != self.last_show_line_numbers || - (self.font_size - self.last_font_size).abs() > 0.1 || - self.line_side != self.last_line_side || - (current_viewport_width - self.last_viewport_width).abs() > 1.0 - } + // Use the pre-calculated pixel width with some padding + let longest_line_width = processing_result.longest_line_pixel_width + 20.0; - /// Update the cached width calculation state - pub fn update_width_calculation_state(&mut self, ctx: &egui::Context, width: f32) { - self.cached_width = Some(width); - self.last_word_wrap = self.word_wrap; - self.last_show_line_numbers = self.show_line_numbers; - self.last_font_size = self.font_size; - self.last_line_side = self.line_side; - self.last_viewport_width = ctx.available_rect().width(); - } - - /// Get cached width if available, otherwise return None to indicate calculation is needed - pub fn get_cached_width(&self) -> Option { - self.cached_width + // Return the larger of the calculated width or minimum available width + let dimensions = self.calculate_editor_dimensions(ui); + longest_line_width.max(dimensions.text_width) } } diff --git a/src/app/tab.rs b/src/app/tab.rs index 6f1a56c..4e88e83 100644 --- a/src/app/tab.rs +++ b/src/app/tab.rs @@ -15,7 +15,7 @@ pub struct Tab { pub file_path: Option, pub is_modified: bool, pub title: String, - hasher: DefaultHasher, + pub hasher: DefaultHasher, } impl Tab { @@ -29,7 +29,7 @@ impl Tab { content, file_path: None, is_modified: false, - title: format!("new_{}", tab_number), + title: format!("new_{tab_number}"), hasher, } } diff --git a/src/io.rs b/src/io.rs index 81e8927..1ad55e3 100644 --- a/src/io.rs +++ b/src/io.rs @@ -1,5 +1,5 @@ -use crate::app::tab::Tab; use crate::app::TextEditor; +use crate::app::tab::Tab; use std::fs; use std::path::PathBuf; @@ -43,7 +43,7 @@ pub(crate) fn open_file(app: &mut TextEditor) { } } Err(err) => { - eprintln!("Failed to open file: {}", err); + eprintln!("Failed to open file: {err}"); } } } @@ -81,7 +81,7 @@ pub(crate) fn save_to_path(app: &mut TextEditor, path: PathBuf) { active_tab.mark_as_saved(); } Err(err) => { - eprintln!("Failed to save file: {}", err); + eprintln!("Failed to save file: {err}"); } } } diff --git a/src/main.rs b/src/main.rs index f6fedc7..fa70f02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,15 +11,15 @@ fn main() -> eframe::Result { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_min_inner_size([600.0, 400.0]) - .with_title("C-Ext") - .with_app_id("io.lampnet.c-ext"), + .with_title("ced") + .with_app_id("io.lampnet.ced"), ..Default::default() }; let config = Config::load(); eframe::run_native( - "C-Ext", + "ced", options, Box::new(move |cc| Ok(Box::new(TextEditor::from_config_with_context(config, cc)))), ) diff --git a/src/ui/central_panel.rs b/src/ui/central_panel.rs index b7be3d2..4867a50 100644 --- a/src/ui/central_panel.rs +++ b/src/ui/central_panel.rs @@ -14,7 +14,7 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { let line_side = app.line_side; let font_size = app.font_size; - egui::CentralPanel::default() + let output = egui::CentralPanel::default() .frame(egui::Frame::NONE) .show(ctx, |ui| { let bg_color = ui.visuals().extreme_bg_color; @@ -109,4 +109,55 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { } }); }); + + output.response.context_menu(|ui| { + let text_len = app.get_active_tab().unwrap().content.len(); + let reset_zoom_key = egui::Id::new("editor_reset_zoom"); + + if ui.button("Cut").clicked() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestCut); + ui.close_menu(); + } + if ui.button("Copy").clicked() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestCopy); + ui.close_menu(); + } + if ui.button("Paste").clicked() { + ui.ctx() + .send_viewport_cmd(egui::ViewportCommand::RequestPaste); + ui.close_menu(); + } + if ui.button("Delete").clicked() { + ui.ctx().input_mut(|i| { + i.events.push(egui::Event::Key { + key: egui::Key::Delete, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }) + }); + ui.close_menu(); + } + if ui.button("Select All").clicked() { + let text_edit_id = egui::Id::new("main_text_editor"); + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { + let select_all_range = egui::text::CCursorRange::two( + egui::text::CCursor::new(0), + egui::text::CCursor::new(text_len), + ); + state.cursor.set_char_range(Some(select_all_range)); + egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); + } + ui.close_menu(); + } + ui.separator(); + if ui.button("Reset Zoom").clicked() { + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(reset_zoom_key, true); + }); + ui.close_menu(); + } + }); + } diff --git a/src/ui/central_panel/editor.rs b/src/ui/central_panel/editor.rs index e060a15..738e213 100644 --- a/src/ui/central_panel/editor.rs +++ b/src/ui/central_panel/editor.rs @@ -1,28 +1,21 @@ use crate::app::TextEditor; use eframe::egui; -use super::find_highlight::draw_find_highlight; - -pub(super) fn editor_view( - ui: &mut egui::Ui, - app: &mut TextEditor, -) -> (egui::Response, Option) { - let current_match_position = app.get_current_match_position(); +pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::Response { + let _current_match_position = app.get_current_match_position(); let show_find = app.show_find; - let prev_show_find = app.prev_show_find; + let _prev_show_find = app.prev_show_find; let show_preferences = app.show_preferences; let show_about = app.show_about; let show_shortcuts = app.show_shortcuts; let word_wrap = app.word_wrap; let font_size = app.font_size; - // Check if reset zoom was requested in previous frame let reset_zoom_key = egui::Id::new("editor_reset_zoom"); - let should_reset_zoom = ui.ctx().memory_mut(|mem| { - mem.data.get_temp::(reset_zoom_key).unwrap_or(false) - }); - - // Reset zoom if requested + let should_reset_zoom = ui + .ctx() + .memory_mut(|mem| mem.data.get_temp::(reset_zoom_key).unwrap_or(false)); + if should_reset_zoom { app.zoom_factor = 1.0; ui.ctx().set_zoom_factor(1.0); @@ -31,196 +24,151 @@ pub(super) fn editor_view( }); } - if let Some(active_tab) = app.get_active_tab_mut() { - let bg_color = ui.visuals().extreme_bg_color; - let editor_rect = ui.available_rect_before_wrap(); - ui.painter().rect_filled(editor_rect, 0.0, bg_color); - - let desired_width = if word_wrap { - ui.available_width() - } else { - f32::INFINITY - }; - - let text_edit = egui::TextEdit::multiline(&mut active_tab.content) - .frame(false) - .font(egui::TextStyle::Monospace) - .code_editor() - .desired_width(desired_width) - .desired_rows(0) - .lock_focus(true) - .cursor_at_end(false) - .id(egui::Id::new("main_text_editor")); - - let output = text_edit.show(ui); - - // Store text length for context menu - let text_len = active_tab.content.len(); - - // Right-click context menu - output.response.context_menu(|ui| { - if ui.button("Cut").clicked() { - ui.ctx().input_mut(|i| i.events.push(egui::Event::Cut)); - ui.close_menu(); - } - if ui.button("Copy").clicked() { - ui.ctx().input_mut(|i| i.events.push(egui::Event::Copy)); - ui.close_menu(); - } - if ui.button("Paste").clicked() { - ui.ctx() - .send_viewport_cmd(egui::ViewportCommand::RequestPaste); - ui.close_menu(); - } - if ui.button("Delete").clicked() { - ui.ctx().input_mut(|i| { - i.events.push(egui::Event::Key { - key: egui::Key::Delete, - physical_key: None, - pressed: true, - repeat: false, - modifiers: egui::Modifiers::NONE, - }) - }); - ui.close_menu(); - } - if ui.button("Select All").clicked() { - let text_edit_id = egui::Id::new("main_text_editor"); - if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { - let select_all_range = egui::text::CCursorRange::two( - egui::text::CCursor::new(0), - egui::text::CCursor::new(text_len), - ); - state.cursor.set_char_range(Some(select_all_range)); - egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); - } - ui.close_menu(); - } - ui.separator(); - if ui.button("Reset Zoom").clicked() { - ui.ctx().memory_mut(|mem| { - mem.data.insert_temp(reset_zoom_key, true); - }); - ui.close_menu(); - } - }); - - let cursor_rect = if let Some(cursor_range) = output.state.cursor.char_range() { - let cursor_pos = cursor_range.primary.index; - let content = &active_tab.content; - - // Count newlines up to cursor position using char_indices to avoid char boundary issues - let cursor_line = content - .char_indices() - .take_while(|(byte_pos, _)| *byte_pos < cursor_pos) - .filter(|(_, ch)| *ch == '\n') - .count(); - - let font_id = ui - .style() - .text_styles - .get(&egui::TextStyle::Monospace) - .unwrap_or(&egui::FontId::monospace(font_size)) - .clone(); - let line_height = ui.fonts(|fonts| fonts.row_height(&font_id)); - - let y_pos = output.response.rect.top() + (cursor_line as f32 * line_height); - let cursor_rect = egui::Rect::from_min_size( - egui::pos2(output.response.rect.left(), y_pos), - egui::vec2(2.0, line_height), - ); - Some(cursor_rect) - } else { - None - }; - - if !show_find && prev_show_find { - if let Some((start_pos, end_pos)) = current_match_position { - let text_edit_id = egui::Id::new("main_text_editor"); - if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { - let cursor_range = egui::text::CCursorRange::two( - egui::text::CCursor::new(start_pos), - egui::text::CCursor::new(end_pos), - ); - state.cursor.set_char_range(Some(cursor_range)); - egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); - } - } - } - - if show_find { - if let Some((start_pos, end_pos)) = current_match_position { - draw_find_highlight( - ui, - &active_tab.content, - start_pos, - end_pos, - output.response.rect, - font_size, - ); - } - } - - if output.response.changed() { - active_tab.update_modified_state(); - app.find_matches.clear(); - app.current_match_index = None; - } - - if !output.response.has_focus() - && !show_preferences - && !show_about - && !show_shortcuts - && !show_find - { - output.response.request_focus(); - } - (output.response, cursor_rect) + let estimated_width = if !word_wrap { + app.calculate_content_based_width(ui) } else { - (ui.label("No file open, how did you get here?"), None) - } -} + 0.0 + }; -pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) { - let word_wrap = app.word_wrap; + let Some(active_tab) = app.get_active_tab_mut() else { + return ui.label("No file open, how did you get here?"); + }; - if word_wrap { - let (_response, _cursor_rect) = editor_view(ui, app); + let bg_color = ui.visuals().extreme_bg_color; + let editor_rect = ui.available_rect_before_wrap(); + ui.painter().rect_filled(editor_rect, 0.0, bg_color); + + let desired_width = if word_wrap { + ui.available_width() } else { - let estimated_width = app.calculate_content_based_width(ui); - let output = egui::ScrollArea::horizontal() + f32::INFINITY + }; + + let text_edit = egui::TextEdit::multiline(&mut active_tab.content) + .frame(false) + .font(egui::TextStyle::Monospace) + .code_editor() + .desired_width(desired_width) + .desired_rows(0) + .lock_focus(true) + .cursor_at_end(false) + .id(egui::Id::new("main_text_editor")); + + let output = if word_wrap { + text_edit.show(ui) + } else { + egui::ScrollArea::horizontal() .auto_shrink([false; 2]) .show(ui, |ui| { ui.allocate_ui_with_layout( egui::Vec2::new(estimated_width, ui.available_height()), egui::Layout::left_to_right(egui::Align::TOP), - |ui| editor_view(ui, app), + |ui| text_edit.show(ui), ) - }); + }) + .inner + .inner + }; - let editor_response = &output.inner.inner.0; - if let Some(cursor_rect) = output.inner.inner.1 { - let text_edit_id = egui::Id::new("main_text_editor"); - let current_cursor_pos = - if let Some(state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { - state.cursor.char_range().map(|range| range.primary.index) - } else { - None - }; + let content_changed = output.response.changed(); + let content_for_processing = if content_changed { + active_tab.update_modified_state(); + Some(active_tab.content.clone()) + } else { + None + }; - let cursor_moved = current_cursor_pos != app.previous_cursor_position; - let text_changed = editor_response.changed(); - let should_scroll = (cursor_moved || text_changed) - && { - let visible_area = ui.clip_rect(); - !visible_area.intersects(cursor_rect) - }; + let current_cursor_pos = output + .state + .cursor + .char_range() + .map(|range| range.primary.index); - if should_scroll { - ui.scroll_to_rect(cursor_rect, Some(egui::Align::Center)); + + if let Some(content) = content_for_processing { + let previous_content = app.previous_content.clone(); + let previous_cursor_pos = app.previous_cursor_char_index; + + if !previous_content.is_empty() { + if let (Some(prev_cursor_pos), Some(curr_cursor_pos)) = + (previous_cursor_pos, current_cursor_pos) + { + app.process_incremental_change( + &previous_content, + &content, + prev_cursor_pos, + curr_cursor_pos, + ui, + ); + } else { + app.process_text_for_rendering(&content, ui); + + if let Some(cursor_pos) = current_cursor_pos { + app.current_cursor_line = content[..cursor_pos] + .bytes() + .filter(|&b| b == b'\n') + .count(); + } } + } - app.previous_cursor_position = current_cursor_pos; + app.previous_content = content.clone(); + app.previous_cursor_char_index = current_cursor_pos; + + if let Some(active_tab) = app.get_active_tab_mut() { + active_tab.last_content_hash = + crate::app::tab::compute_content_hash(&active_tab.content, &mut active_tab.hasher); } } + + if !word_wrap { + if let Some(cursor_pos) = current_cursor_pos { + let cursor_moved = Some(cursor_pos) != app.previous_cursor_position; + let text_changed = output.response.changed(); + + if cursor_moved || text_changed { + if let Some(active_tab) = app.get_active_tab() { + let content = &active_tab.content; + let cursor_line = content + .char_indices() + .take_while(|(byte_pos, _)| *byte_pos < cursor_pos) + .filter(|(_, ch)| *ch == '\n') + .count(); + + let font_id = ui + .style() + .text_styles + .get(&egui::TextStyle::Monospace) + .unwrap_or(&egui::FontId::monospace(font_size)) + .clone(); + let line_height = ui.fonts(|fonts| fonts.row_height(&font_id)); + + let y_pos = output.response.rect.top() + (cursor_line as f32 * line_height); + let cursor_rect = egui::Rect::from_min_size( + egui::pos2(output.response.rect.left(), y_pos), + egui::vec2(2.0, line_height), + ); + + let visible_area = ui.clip_rect(); + if !visible_area.intersects(cursor_rect) { + ui.scroll_to_rect(cursor_rect, Some(egui::Align::Center)); + } + } + } + + app.previous_cursor_position = Some(cursor_pos); + } + } + + // Request focus if no dialogs are open + if !output.response.has_focus() + && !show_preferences + && !show_about + && !show_shortcuts + && !show_find + { + output.response.request_focus(); + } + + output.response } diff --git a/src/ui/menu_bar.rs b/src/ui/menu_bar.rs index 2c41cc8..60094ab 100644 --- a/src/ui/menu_bar.rs +++ b/src/ui/menu_bar.rs @@ -11,12 +11,12 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { let should_show_menubar = !app.auto_hide_toolbar || { if app.menu_interaction_active { - app.menu_bar_stable_until = Some(now + std::time::Duration::from_millis(500)); + app.menu_bar_stable_until = Some(now + std::time::Duration::from_millis(16)); true } else if should_stay_stable { true } else if let Some(pointer_pos) = ctx.pointer_hover_pos() { - let in_menu_trigger_area = pointer_pos.y < 10.0; + let in_menu_trigger_area = pointer_pos.y < 5.0; if in_menu_trigger_area { app.menu_bar_stable_until = Some(now + std::time::Duration::from_millis(300)); @@ -192,6 +192,13 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { app.save_config(); ui.close_menu(); } + if ui + .checkbox(&mut app.auto_hide_tab_bar, "Auto Hide Tab Bar") + .clicked() + { + app.save_config(); + ui.close_menu(); + } ui.separator(); @@ -265,6 +272,41 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { ui.close_menu(); } }); + + if app.auto_hide_tab_bar { + let tab_title = if let Some(tab) = app.get_active_tab() { + tab.title.clone() + } else { + let empty_tab = crate::app::tab::Tab::new_empty(1); + empty_tab.title.clone() + }; + + let window_width = ctx.screen_rect().width(); + + let text_galley = ui.fonts(|fonts| { + fonts.layout_job(egui::text::LayoutJob::simple_singleline( + tab_title.clone(), + app.get_font_id(), + ui.style().visuals.text_color(), + )) + }); + + let text_width = text_galley.size().x; + let text_height = text_galley.size().y; + + let window_center_x = window_width / 2.0; + let text_x = (window_center_x - text_width / 2.0).max(0.0); + + let cursor_pos = ui.cursor().left_top(); + + ui.painter().galley( + egui::pos2(text_x, cursor_pos.y), + text_galley, + ui.style().visuals.text_color(), + ); + + ui.allocate_exact_size(egui::vec2(0.0, text_height), egui::Sense::hover()); + } }); }); } diff --git a/src/ui/shortcuts_window.rs b/src/ui/shortcuts_window.rs index 2c67198..74d2b85 100644 --- a/src/ui/shortcuts_window.rs +++ b/src/ui/shortcuts_window.rs @@ -27,9 +27,7 @@ fn render_shortcuts_content(ui: &mut egui::Ui) { ui.separator(); ui.label(egui::RichText::new("Views").size(18.0).strong()); ui.label(egui::RichText::new("Ctrl + L: Toggle Line Numbers").size(14.0)); - ui.label( - egui::RichText::new("Ctrl + Shift + L: Change Line Number Side").size(14.0), - ); + ui.label(egui::RichText::new("Ctrl + Shift + L: Change Line Number Side").size(14.0)); ui.label(egui::RichText::new("Ctrl + K: Toggle Word Wrap").size(14.0)); ui.label(egui::RichText::new("Ctrl + H: Toggle Auto Hide Toolbar").size(14.0)); ui.label(egui::RichText::new("Ctrl + P: Preferences").size(14.0)); @@ -51,10 +49,10 @@ fn render_shortcuts_content(ui: &mut egui::Ui) { pub(crate) fn shortcuts_window(app: &mut TextEditor, ctx: &egui::Context) { let visuals = &ctx.style().visuals; let screen_rect = ctx.screen_rect(); - + // Calculate appropriate window size that always fits nicely in the main window - let window_width = (screen_rect.width() * 0.6).min(400.0).max(300.0); - let window_height = (screen_rect.height() * 0.7).min(500.0).max(250.0); + let window_width = (screen_rect.width() * 0.6).clamp(300.0, 400.0); + let window_height = (screen_rect.height() * 0.7).clamp(250.0, 500.0); egui::Window::new("Shortcuts") .collapsible(false)