From 3918bbff9323cced0fd42584b0dc5ca987329c3a Mon Sep 17 00:00:00 2001 From: candle Date: Wed, 9 Jul 2025 17:30:03 -0400 Subject: [PATCH 1/5] chars index properly now, added readme --- .gitignore | 1 + LICENSE-MIT | 25 ++++++++++++ README.md | 75 ++++++++++++++++++++++++++++++++++ src/ui/central_panel/editor.rs | 8 +++- 4 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 LICENSE-MIT create mode 100644 README.md diff --git a/.gitignore b/.gitignore index f6d65e3..4f2f988 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.c* Cargo.lock /target perf.* diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..f29028a --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) Filip Bicki + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..046e00e --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# ced - candle's Editor + +There is a disturbing lack of simple GUI text editors available on Linux natively. The world of TUI editors is flourishing, but regular people don't 'yank to system register' when they want to move text from one file to another. In the world of GUI text editors you have a few options, all with their own caveats:\ +`gedit` -> Good for the GNOME Desktop, but uses GTK-4.0 so it stands out anywhere else.\ +`Kate` -> If you're not on KDE already, it comes with tons of overhead (52 packages on Arch Linux for an application to write text).\ +`Emacs` -> Requires a degree in understanding its documentation. + +(c)andle's (Ed)itor aims to be a single ~~hopefully small~~ binary for that one purpose. +## Features + +* Sane text editing with standard keybindings (`Ctrl+A`, `Ctrl+C`, etc.). +* Opens with a blank slate for quick typing, remember Notepad? +* Separate UI zoom that doesn't affect font size (`Ctrl+Shift` + `+`/`-`). +* Ricers rejoice, your `pywal` colors will be used! +* Weirdly smooth typing experience. + +## Build and Install +##### Requirements +`git`, `rust`/`rustup`/`cargo` +##### Arch Linux +`sudo pacman -S git rust` +##### Ubuntu/Debian +`sudo apt install git rust` + +#### Install +```bash +git clone https://code.lampnet.io/candle/ced +cd ced && cargo build --release +sudo mv target/release/ced /usr/local/bin/ +sudo install -Dm644 ced.desktop /usr/share/applications/ced.desktop +``` + +`ced` should now appear as 'Text Editor' in your application launcher. You can remove the cloned directory at this point. + +## Configuration + +`ced` will look for, and create if needed, a configuration file at: `$XDG_CONFIG_HOME/ced/config.toml`. + +Here is an example `config.toml`: + +```toml +auto_hide_toolbar = false +show_line_numbers = false +word_wrap = false +theme = "System" +line_side = false +font_family = "Monospace" +font_size = 16.0 +``` + +### Options + +| Option | Default | Description | +|--------|---------|-------------| +| `auto_hide_toolbar` | `false` | If `true`, the menu bar at the top will be hidden. Move your mouse to the top of the window to reveal it. | +| `show_line_numbers` | `false` | If `true`, line numbers will be displayed on the side specified by `line_side`. | +| `line_side` | `false` | If `false`, line numbers are on the left. If `true`, they are on the right. | +| `word_wrap` | `false` | If `true`, lines will wrap when they reach the edge of the window. | +| `font_family` | `"Proportional"` | The font family used for the editor text. | +| `font_size` | `14.0` | `8.0-32.0` The font size for text editing. | +| `theme` | `"System"` | The color scheme for the application. Options: `"System"` (attempts to use colors from `$XDG_CACHE_HOME/wal/colors` if present, otherwise uses system's light/dark mode preference), `"Light"`, or `"Dark"` (manually specify a theme). | + +## Future Plans +In order of importance. +| Feature | Info | +| ------- | ---- | +| **Find/Replace:** | In progress. | +| **State/Cache:** | A toggleable option to keep an application state and prevent "Quit without saving" warnings. | +| **Syntax Highlighting/LSP:** | Looking at allowing you to use/attach your own tools for this. | +| **Choose Font** | More than just Monospace/Proportional. | +| **Vim Mode:** | It's in-escapable. | +| **CLI Mode:** | 💀 | +| **IDE MODE:** | 🤡 | + +I use [Helix](https://helix-editor.com/), btw. diff --git a/src/ui/central_panel/editor.rs b/src/ui/central_panel/editor.rs index a014200..e060a15 100644 --- a/src/ui/central_panel/editor.rs +++ b/src/ui/central_panel/editor.rs @@ -109,8 +109,12 @@ pub(super) fn editor_view( let cursor_pos = cursor_range.primary.index; let content = &active_tab.content; - let text_up_to_cursor = &content[..cursor_pos.min(content.len())]; - let cursor_line = text_up_to_cursor.chars().filter(|&c| c == '\n').count(); + // 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() From 1ed06bbe37560165a61eaa8db3c891d0f25ec0de Mon Sep 17 00:00:00 2001 From: candle Date: Tue, 15 Jul 2025 00:42:01 -0400 Subject: [PATCH 2/5] hide tab bar, smarter text processing --- src/app/config.rs | 4 +- src/app/state/app_impl.rs | 88 +-------- src/app/state/config.rs | 21 +-- src/app/state/default.rs | 15 +- src/app/state/editor.rs | 28 ++- src/app/state/lifecycle.rs | 2 +- src/app/state/processing.rs | 297 ++++++++++++++++++++++++++---- src/app/state/ui.rs | 88 +++------ src/app/tab.rs | 4 +- src/io.rs | 6 +- src/main.rs | 6 +- src/ui/central_panel.rs | 53 +++++- src/ui/central_panel/editor.rs | 326 ++++++++++++++------------------- src/ui/menu_bar.rs | 46 ++++- src/ui/shortcuts_window.rs | 10 +- 15 files changed, 568 insertions(+), 426 deletions(-) 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) From 46bca1c6cbe4bb22869d5e80445f292b1fbba536 Mon Sep 17 00:00:00 2001 From: candle Date: Tue, 15 Jul 2025 09:53:45 -0400 Subject: [PATCH 3/5] fix font zooming stuff --- Cargo.toml | 2 +- src/app/state/config.rs | 1 + src/app/state/default.rs | 1 + src/app/state/editor.rs | 1 + src/app/state/ui.rs | 27 +++++++++++++++++++++------ src/ui/central_panel/editor.rs | 11 +++++++++++ src/ui/menu_bar.rs | 22 +++++++++++----------- src/ui/preferences_window.rs | 4 ++-- 8 files changed, 49 insertions(+), 20 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e48f1ed..a3242f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ced" -version = "0.0.3" +version = "0.0.4" edition = "2024" [dependencies] diff --git a/src/app/state/config.rs b/src/app/state/config.rs index 58f6ca4..d7d8814 100644 --- a/src/app/state/config.rs +++ b/src/app/state/config.rs @@ -46,6 +46,7 @@ impl TextEditor { previous_cursor_char_index: None, current_cursor_line: 0, previous_cursor_line: 0, + font_settings_changed: false, } } diff --git a/src/app/state/default.rs b/src/app/state/default.rs index e4cd95c..2044f9f 100644 --- a/src/app/state/default.rs +++ b/src/app/state/default.rs @@ -46,6 +46,7 @@ impl Default for TextEditor { previous_cursor_char_index: None, current_cursor_line: 0, previous_cursor_line: 0, + font_settings_changed: false, } } } diff --git a/src/app/state/editor.rs b/src/app/state/editor.rs index 9b136a2..d9c082f 100644 --- a/src/app/state/editor.rs +++ b/src/app/state/editor.rs @@ -70,4 +70,5 @@ pub struct TextEditor { 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 + pub(crate) font_settings_changed: bool, // Flag to trigger text reprocessing when font changes } diff --git a/src/app/state/ui.rs b/src/app/state/ui.rs index a4951e7..cec69b0 100644 --- a/src/app/state/ui.rs +++ b/src/app/state/ui.rs @@ -32,11 +32,13 @@ impl TextEditor { egui::FontId::new(self.font_size, font_family) } + /// Immediately apply theme and save to configuration pub fn set_theme(&mut self, ctx: &egui::Context) { theme::apply(self.theme, ctx); self.save_config(); } + /// Apply font settings with immediate text reprocessing pub fn apply_font_settings(&mut self, ctx: &egui::Context) { let font_family = match self.font_family.as_str() { "Monospace" => egui::FontFamily::Monospace, @@ -50,9 +52,27 @@ impl TextEditor { ); ctx.set_style(style); + self.font_settings_changed = true; self.save_config(); } + /// Apply font settings with immediate text reprocessing + pub fn apply_font_settings_with_ui(&mut self, ctx: &egui::Context, ui: &egui::Ui) { + self.apply_font_settings(ctx); + self.reprocess_text_for_font_change(ui); + self.font_settings_changed = false; + } + + /// Trigger immediate text reprocessing when font settings change + pub fn reprocess_text_for_font_change(&mut self, ui: &egui::Ui) { + if let Some(active_tab) = self.get_active_tab() { + let content = active_tab.content.clone(); + if !content.is_empty() { + self.process_text_for_rendering(&content, ui); + } + } + } + /// 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(); @@ -65,11 +85,9 @@ 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(); let sample_text = "9".repeat(line_count_digits); @@ -80,7 +98,6 @@ impl TextEditor { .x }); - // Add padding based on line_side setting let line_number_width = if self.line_side { base_line_number_width + 20.0 } else { @@ -108,10 +125,8 @@ impl TextEditor { return self.calculate_editor_dimensions(ui).text_width; } - // Use the pre-calculated pixel width with some padding - let longest_line_width = processing_result.longest_line_pixel_width + 20.0; + let longest_line_width = processing_result.longest_line_pixel_width + self.font_size; - // 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/ui/central_panel/editor.rs b/src/ui/central_panel/editor.rs index 738e213..6ece007 100644 --- a/src/ui/central_panel/editor.rs +++ b/src/ui/central_panel/editor.rs @@ -121,6 +121,17 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R } } + // Check if font settings changed and trigger reprocessing + if app.font_settings_changed { + if let Some(active_tab) = app.get_active_tab() { + let content = active_tab.content.clone(); + if !content.is_empty() { + app.process_text_for_rendering(&content, ui); + } + } + app.font_settings_changed = false; + } + if !word_wrap { if let Some(cursor_pos) = current_cursor_pos { let cursor_moved = Some(cursor_pos) != app.previous_cursor_position; diff --git a/src/ui/menu_bar.rs b/src/ui/menu_bar.rs index 60094ab..6dcd98b 100644 --- a/src/ui/menu_bar.rs +++ b/src/ui/menu_bar.rs @@ -172,7 +172,7 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { ui.menu_button("View", |ui| { app.menu_interaction_active = true; if ui - .checkbox(&mut app.show_line_numbers, "Toggle Line Numbers") + .checkbox(&mut app.show_line_numbers, "Show Line Numbers") .clicked() { app.save_config(); @@ -272,7 +272,7 @@ 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() @@ -280,31 +280,31 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { 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(), + tab_title, + ui.style().text_styles[&egui::TextStyle::Body].clone(), 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/preferences_window.rs b/src/ui/preferences_window.rs index 105f309..be33a80 100644 --- a/src/ui/preferences_window.rs +++ b/src/ui/preferences_window.rs @@ -58,7 +58,7 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { }); if changed { - app.apply_font_settings(ctx); + app.apply_font_settings_with_ui(ctx, ui); } }); @@ -93,7 +93,7 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { let clamped_size = new_size.clamp(8.0, 32.0); if (app.font_size - clamped_size).abs() > 0.1 { app.font_size = clamped_size; - app.apply_font_settings(ctx); + app.apply_font_settings_with_ui(ctx, ui); } } app.font_size_input = None; From 214cba5aa33c8d9e386935c629171b3428b7d0d4 Mon Sep 17 00:00:00 2001 From: candle Date: Tue, 15 Jul 2025 10:43:41 -0400 Subject: [PATCH 4/5] better wording, defaults, fix double click selection --- src/app/state/default.rs | 2 +- src/ui/central_panel.rs | 41 ++++++++++++++++++++++++++++++------ src/ui/menu_bar.rs | 25 ++++++++++++++-------- src/ui/preferences_window.rs | 2 +- 4 files changed, 53 insertions(+), 17 deletions(-) diff --git a/src/app/state/default.rs b/src/app/state/default.rs index 2044f9f..e94fbce 100644 --- a/src/app/state/default.rs +++ b/src/app/state/default.rs @@ -19,7 +19,7 @@ impl Default for TextEditor { show_line_numbers: false, word_wrap: true, auto_hide_toolbar: false, - auto_hide_tab_bar: false, + auto_hide_tab_bar: true, theme: Theme::default(), line_side: false, font_family: "Proportional".to_string(), diff --git a/src/ui/central_panel.rs b/src/ui/central_panel.rs index 4867a50..bc1da77 100644 --- a/src/ui/central_panel.rs +++ b/src/ui/central_panel.rs @@ -23,10 +23,19 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { let editor_height = panel_rect.height(); if !show_line_numbers || app.get_active_tab().is_none() { - egui::ScrollArea::vertical() + let scroll_response = egui::ScrollArea::vertical() .auto_shrink([false; 2]) .show(ui, |ui| { - editor_view_ui(ui, app); + // Create an invisible interaction area for context menu that covers the whole area + let full_rect = ui.available_rect_before_wrap(); + let context_response = ui.allocate_response(full_rect.size(), egui::Sense::click()); + + // Reset cursor to render editor at the top + ui.allocate_ui_at_rect(full_rect, |ui| { + editor_view_ui(ui, app); + }); + + show_context_menu(ui, app, &context_response); }); return; } @@ -87,7 +96,16 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { egui::vec2(editor_dimensions.text_width, editor_height), egui::Layout::left_to_right(egui::Align::TOP), |ui| { - editor_view_ui(ui, app); + // Create an invisible interaction area for context menu + let full_rect = ui.available_rect_before_wrap(); + let context_response = ui.allocate_response(full_rect.size(), egui::Sense::click()); + + // Reset cursor to render editor at the top + ui.allocate_ui_at_rect(full_rect, |ui| { + editor_view_ui(ui, app); + }); + + show_context_menu(ui, app, &context_response); }, ); separator_widget(ui); @@ -103,14 +121,26 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { |ui| { line_numbers_widget(ui); separator_widget(ui); - editor_view_ui(ui, app); + + // Create an invisible interaction area for context menu + let editor_area = ui.available_rect_before_wrap(); + let context_response = ui.allocate_response(editor_area.size(), egui::Sense::click()); + + // Reset cursor to render editor at the current position + ui.allocate_ui_at_rect(editor_area, |ui| { + editor_view_ui(ui, app); + }); + + show_context_menu(ui, app, &context_response); }, ); } }); }); +} - output.response.context_menu(|ui| { +fn show_context_menu(ui: &mut egui::Ui, app: &mut TextEditor, context_response: &egui::Response) { + context_response.context_menu(|ui| { let text_len = app.get_active_tab().unwrap().content.len(); let reset_zoom_key = egui::Id::new("editor_reset_zoom"); @@ -159,5 +189,4 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { ui.close_menu(); } }); - } diff --git a/src/ui/menu_bar.rs b/src/ui/menu_bar.rs index 6dcd98b..c47437d 100644 --- a/src/ui/menu_bar.rs +++ b/src/ui/menu_bar.rs @@ -179,7 +179,14 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { ui.close_menu(); } if ui - .checkbox(&mut app.word_wrap, "Toggle Word Wrap") + .checkbox(&mut app.word_wrap, "Word Wrap") + .clicked() + { + app.save_config(); + ui.close_menu(); + } + if ui + .checkbox(&mut app.auto_hide_tab_bar, "Hide Tab Bar") .clicked() { app.save_config(); @@ -192,13 +199,6 @@ 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(); @@ -282,11 +282,18 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { }; let window_width = ctx.screen_rect().width(); + let font_id = ui.style().text_styles[&egui::TextStyle::Body].clone(); + + let tab_title = if app.get_active_tab().is_some_and(|tab| tab.is_modified) { + format!("{tab_title}*") + } else { + tab_title + }; let text_galley = ui.fonts(|fonts| { fonts.layout_job(egui::text::LayoutJob::simple_singleline( tab_title, - ui.style().text_styles[&egui::TextStyle::Body].clone(), + font_id, ui.style().visuals.text_color(), )) }); diff --git a/src/ui/preferences_window.rs b/src/ui/preferences_window.rs index be33a80..6291fc7 100644 --- a/src/ui/preferences_window.rs +++ b/src/ui/preferences_window.rs @@ -72,7 +72,7 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { app.font_size_input = Some(app.font_size.to_string()); } - let mut font_size_text = app.font_size_input.as_ref().unwrap().clone(); + let mut font_size_text = app.font_size_input.as_ref().unwrap_or(&"14".to_string()).clone(); let response = ui.add( egui::TextEdit::singleline(&mut font_size_text) .desired_width(50.0) From 83cff76b5ae92899f700c15bb8760cc8d02792c4 Mon Sep 17 00:00:00 2001 From: candle Date: Tue, 15 Jul 2025 11:07:41 -0400 Subject: [PATCH 5/5] removed warnings, deprecated methods, formatting fixes --- src/app/shortcuts.rs | 6 +- src/app/state/app_impl.rs | 16 +- src/app/state/config.rs | 2 +- src/app/state/default.rs | 6 +- src/app/state/editor.rs | 16 +- src/app/state/processing.rs | 231 +++++++++++++++---------- src/app/theme.rs | 2 +- src/io.rs | 2 +- src/main.rs | 2 +- src/ui/central_panel.rs | 78 +++++---- src/ui/central_panel/editor.rs | 1 - src/ui/central_panel/find_highlight.rs | 9 +- src/ui/central_panel/line_numbers.rs | 3 +- src/ui/menu_bar.rs | 5 +- src/ui/preferences_window.rs | 16 +- 15 files changed, 230 insertions(+), 165 deletions(-) diff --git a/src/app/shortcuts.rs b/src/app/shortcuts.rs index 7d65578..4319e72 100644 --- a/src/app/shortcuts.rs +++ b/src/app/shortcuts.rs @@ -143,7 +143,7 @@ fn get_shortcuts() -> Vec { ] } -fn execute_action(action: ShortcutAction, editor: &mut TextEditor, ctx: &egui::Context) -> bool { +fn execute_action(action: ShortcutAction, editor: &mut TextEditor, _ctx: &egui::Context) -> bool { match action { ShortcutAction::NewFile => { io::new_file(editor); @@ -171,7 +171,9 @@ fn execute_action(action: ShortcutAction, editor: &mut TextEditor, ctx: &egui::C if let Some(current_tab) = editor.get_active_tab() { if current_tab.is_modified { // Show dialog for unsaved changes - editor.pending_unsaved_action = Some(super::state::UnsavedAction::CloseTab(editor.active_tab_index)); + editor.pending_unsaved_action = Some( + super::state::UnsavedAction::CloseTab(editor.active_tab_index), + ); } else { // Close tab directly if no unsaved changes editor.close_tab(editor.active_tab_index); diff --git a/src/app/state/app_impl.rs b/src/app/state/app_impl.rs index 61f1b57..bb39ec5 100644 --- a/src/app/state/app_impl.rs +++ b/src/app/state/app_impl.rs @@ -1,12 +1,12 @@ -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 super::editor::TextEditor; +use crate::app::shortcuts; +use crate::ui::about_window::about_window; +use crate::ui::central_panel::central_panel; +use crate::ui::find_window::find_window; +use crate::ui::menu_bar::menu_bar; +use crate::ui::preferences_window::preferences_window; +use crate::ui::shortcuts_window::shortcuts_window; +use crate::ui::tab_bar::tab_bar; impl eframe::App for TextEditor { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { diff --git a/src/app/state/config.rs b/src/app/state/config.rs index d7d8814..644dd09 100644 --- a/src/app/state/config.rs +++ b/src/app/state/config.rs @@ -30,7 +30,7 @@ impl TextEditor { tab_bar_rect: None, menu_bar_stable_until: None, text_processing_result: std::sync::Arc::new(std::sync::Mutex::new(Default::default())), - processing_thread_handle: None, + _processing_thread_handle: None, find_query: String::new(), find_matches: Vec::new(), current_match_index: None, diff --git a/src/app/state/default.rs b/src/app/state/default.rs index e94fbce..54afebc 100644 --- a/src/app/state/default.rs +++ b/src/app/state/default.rs @@ -30,17 +30,17 @@ impl Default for TextEditor { tab_bar_rect: None, menu_bar_stable_until: None, text_processing_result: Arc::new(Mutex::new(TextProcessingResult::default())), - processing_thread_handle: None, + _processing_thread_handle: None, // Find functionality find_query: String::new(), find_matches: Vec::new(), current_match_index: None, case_sensitive_search: false, prev_show_find: 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, diff --git a/src/app/state/editor.rs b/src/app/state/editor.rs index d9c082f..0211e2d 100644 --- a/src/app/state/editor.rs +++ b/src/app/state/editor.rs @@ -13,8 +13,8 @@ pub enum UnsavedAction { #[derive(Clone)] pub struct TextProcessingResult { pub line_count: usize, - 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_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 } @@ -55,20 +55,20 @@ pub struct TextEditor { pub(crate) tab_bar_rect: Option, pub(crate) menu_bar_stable_until: Option, pub(crate) text_processing_result: Arc>, - pub(crate) processing_thread_handle: Option>, + pub(crate) _processing_thread_handle: Option>, pub(crate) find_query: String, pub(crate) find_matches: Vec<(usize, usize)>, // (start_pos, end_pos) byte positions pub(crate) current_match_index: Option, pub(crate) case_sensitive_search: bool, pub(crate) prev_show_find: bool, // Track previous state to detect transitions - + // 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 - pub(crate) font_settings_changed: bool, // Flag to trigger text reprocessing when font changes + pub(crate) current_cursor_line: usize, // Track current line number incrementally + pub(crate) previous_cursor_line: usize, // Track previous line for comparison + pub(crate) font_settings_changed: bool, // Flag to trigger text reprocessing when font changes } diff --git a/src/app/state/processing.rs b/src/app/state/processing.rs index 3899e5d..3f6e54f 100644 --- a/src/app/state/processing.rs +++ b/src/app/state/processing.rs @@ -6,7 +6,7 @@ impl TextEditor { 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, @@ -17,10 +17,9 @@ impl TextEditor { return; } - // 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 { @@ -29,17 +28,19 @@ impl TextEditor { } } - // 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 + fonts + .layout( + longest_line_text.to_string(), + font_id, + egui::Color32::WHITE, + f32::INFINITY, + ) + .size() + .x }) } else { 0.0 @@ -56,65 +57,106 @@ impl TextEditor { } /// 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 + pub fn process_incremental_change( + &mut self, + old_content: &str, + new_content: &str, + old_cursor_pos: usize, + new_cursor_pos: usize, + ui: &egui::Ui, + ) { + let line_change = self.calculate_cursor_line_change( + old_content, + new_content, + old_cursor_pos, + new_cursor_pos, + ); + 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); + 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); + 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.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 + fn calculate_cursor_line_change( + &self, + old_content: &str, + new_content: &str, + old_cursor_pos: usize, + new_cursor_pos: usize, + ) -> isize { 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 + fn handle_character_replacement( + &mut self, + _old_content: &str, + new_content: &str, + _old_cursor_pos: usize, + new_cursor_pos: usize, + ui: &egui::Ui, + ) { 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); + + 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 + fn handle_content_addition( + &mut self, + old_content: &str, + new_content: &str, + _old_cursor_pos: usize, + new_cursor_pos: usize, + ui: &egui::Ui, + ) { 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; @@ -122,8 +164,7 @@ impl TextEditor { 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; @@ -133,36 +174,42 @@ impl TextEditor { 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); + + 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 + fn handle_content_removal( + &mut self, + old_content: &str, + new_content: &str, + _old_cursor_pos: usize, + new_cursor_pos: usize, + ui: &egui::Ui, + ) { 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; @@ -170,8 +217,7 @@ impl TextEditor { 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; @@ -181,77 +227,80 @@ impl TextEditor { 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.process_text_for_rendering(new_content, ui); } - + 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 { + 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); + 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) { + 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 + fonts + .layout( + line_content.to_string(), + font_id, + egui::Color32::WHITE, + f32::INFINITY, + ) + .size() + .x }); let result = TextProcessingResult { @@ -260,7 +309,7 @@ impl TextEditor { longest_line_length: line_length, longest_line_pixel_width: pixel_width, }; - + self.update_processing_result(result); } } diff --git a/src/app/theme.rs b/src/app/theme.rs index 8f9d083..7761053 100644 --- a/src/app/theme.rs +++ b/src/app/theme.rs @@ -77,7 +77,7 @@ fn get_pywal_colors() -> Option { let fg = parse_color(colors.get(7).unwrap_or(&colors[0]))?; let bg_alt = parse_color(colors.get(8).unwrap_or(&colors[0]))?; let accent = parse_color(colors.get(1).unwrap_or(&colors[0]))?; - let secondary = parse_color(colors.get(2).unwrap_or(&colors[0]))?; + let _secondary = parse_color(colors.get(2).unwrap_or(&colors[0]))?; let mut visuals = if is_dark_color(bg) { egui::Visuals::dark() diff --git a/src/io.rs b/src/io.rs index 1ad55e3..94a5b25 100644 --- a/src/io.rs +++ b/src/io.rs @@ -1,5 +1,5 @@ -use crate::app::TextEditor; use crate::app::tab::Tab; +use crate::app::TextEditor; use std::fs; use std::path::PathBuf; diff --git a/src/main.rs b/src/main.rs index fa70f02..3621bd3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use eframe::egui; mod app; mod io; mod ui; -use app::{TextEditor, config::Config}; +use app::{config::Config, TextEditor}; fn main() -> eframe::Result { let options = eframe::NativeOptions { diff --git a/src/ui/central_panel.rs b/src/ui/central_panel.rs index bc1da77..6233551 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; - let output = egui::CentralPanel::default() + let _output = egui::CentralPanel::default() .frame(egui::Frame::NONE) .show(ctx, |ui| { let bg_color = ui.visuals().extreme_bg_color; @@ -23,20 +23,20 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { let editor_height = panel_rect.height(); if !show_line_numbers || app.get_active_tab().is_none() { - let scroll_response = egui::ScrollArea::vertical() - .auto_shrink([false; 2]) - .show(ui, |ui| { - // Create an invisible interaction area for context menu that covers the whole area - let full_rect = ui.available_rect_before_wrap(); - let context_response = ui.allocate_response(full_rect.size(), egui::Sense::click()); - - // Reset cursor to render editor at the top - ui.allocate_ui_at_rect(full_rect, |ui| { - editor_view_ui(ui, app); + let _scroll_response = + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + let full_rect = ui.available_rect_before_wrap(); + let context_response = + ui.allocate_response(full_rect.size(), egui::Sense::click()); + + ui.scope_builder(egui::UiBuilder::new().max_rect(full_rect), |ui| { + editor_view_ui(ui, app); + }); + + show_context_menu(ui, app, &context_response); }); - - show_context_menu(ui, app, &context_response); - }); return; } @@ -86,7 +86,8 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { .show(ui, |ui| { if line_side { // Line numbers on the right - let text_editor_width = editor_dimensions.text_width + editor_dimensions.total_reserved_width; + let text_editor_width = + editor_dimensions.text_width + editor_dimensions.total_reserved_width; ui.allocate_ui_with_layout( egui::vec2(text_editor_width, editor_height), egui::Layout::left_to_right(egui::Align::TOP), @@ -98,13 +99,19 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { |ui| { // Create an invisible interaction area for context menu let full_rect = ui.available_rect_before_wrap(); - let context_response = ui.allocate_response(full_rect.size(), egui::Sense::click()); - + let context_response = ui.allocate_response( + full_rect.size(), + egui::Sense::click(), + ); + // Reset cursor to render editor at the top - ui.allocate_ui_at_rect(full_rect, |ui| { - editor_view_ui(ui, app); - }); - + ui.scope_builder( + egui::UiBuilder::new().max_rect(full_rect), + |ui| { + editor_view_ui(ui, app); + }, + ); + show_context_menu(ui, app, &context_response); }, ); @@ -114,23 +121,28 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { ); } else { // Line numbers on the left - let text_editor_width = editor_dimensions.text_width + editor_dimensions.total_reserved_width; + let text_editor_width = + editor_dimensions.text_width + editor_dimensions.total_reserved_width; ui.allocate_ui_with_layout( egui::vec2(text_editor_width, editor_height), egui::Layout::left_to_right(egui::Align::TOP), |ui| { line_numbers_widget(ui); separator_widget(ui); - + // Create an invisible interaction area for context menu let editor_area = ui.available_rect_before_wrap(); - let context_response = ui.allocate_response(editor_area.size(), egui::Sense::click()); - + let context_response = + ui.allocate_response(editor_area.size(), egui::Sense::click()); + // Reset cursor to render editor at the current position - ui.allocate_ui_at_rect(editor_area, |ui| { - editor_view_ui(ui, app); - }); - + ui.scope_builder( + egui::UiBuilder::new().max_rect(editor_area), + |ui| { + editor_view_ui(ui, app); + }, + ); + show_context_menu(ui, app, &context_response); }, ); @@ -139,17 +151,19 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { }); } -fn show_context_menu(ui: &mut egui::Ui, app: &mut TextEditor, context_response: &egui::Response) { +fn show_context_menu(_ui: &mut egui::Ui, app: &mut TextEditor, context_response: &egui::Response) { context_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.ctx() + .send_viewport_cmd(egui::ViewportCommand::RequestCut); ui.close_menu(); } if ui.button("Copy").clicked() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::RequestCopy); + ui.ctx() + .send_viewport_cmd(egui::ViewportCommand::RequestCopy); ui.close_menu(); } if ui.button("Paste").clicked() { diff --git a/src/ui/central_panel/editor.rs b/src/ui/central_panel/editor.rs index 6ece007..9a493c6 100644 --- a/src/ui/central_panel/editor.rs +++ b/src/ui/central_panel/editor.rs @@ -84,7 +84,6 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R .char_range() .map(|range| range.primary.index); - if let Some(content) = content_for_processing { let previous_content = app.previous_content.clone(); let previous_cursor_pos = app.previous_cursor_char_index; diff --git a/src/ui/central_panel/find_highlight.rs b/src/ui/central_panel/find_highlight.rs index 6354c34..836e140 100644 --- a/src/ui/central_panel/find_highlight.rs +++ b/src/ui/central_panel/find_highlight.rs @@ -1,6 +1,6 @@ use eframe::egui; -pub(super) fn draw_find_highlight( +pub(super) fn _draw_find_highlight( ui: &mut egui::Ui, content: &str, start_pos: usize, @@ -74,10 +74,7 @@ pub(super) fn draw_find_highlight( egui::vec2(match_width, line_height), ); - ui.painter().rect_filled( - highlight_rect, - 0.0, - ui.visuals().selection.bg_fill, - ); + ui.painter() + .rect_filled(highlight_rect, 0.0, ui.visuals().selection.bg_fill); } } diff --git a/src/ui/central_panel/line_numbers.rs b/src/ui/central_panel/line_numbers.rs index b0d1a87..52c3916 100644 --- a/src/ui/central_panel/line_numbers.rs +++ b/src/ui/central_panel/line_numbers.rs @@ -86,8 +86,7 @@ pub(super) fn render_line_numbers( let bg_color = ui.visuals().extreme_bg_color; let line_numbers_rect = ui.available_rect_before_wrap(); - ui.painter() - .rect_filled(line_numbers_rect, 0.0, bg_color); + ui.painter().rect_filled(line_numbers_rect, 0.0, bg_color); let font_id = egui::FontId::monospace(font_size); let line_count_width = line_count.to_string().len(); diff --git a/src/ui/menu_bar.rs b/src/ui/menu_bar.rs index c47437d..2ffcd80 100644 --- a/src/ui/menu_bar.rs +++ b/src/ui/menu_bar.rs @@ -178,10 +178,7 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { app.save_config(); ui.close_menu(); } - if ui - .checkbox(&mut app.word_wrap, "Word Wrap") - .clicked() - { + if ui.checkbox(&mut app.word_wrap, "Word Wrap").clicked() { app.save_config(); ui.close_menu(); } diff --git a/src/ui/preferences_window.rs b/src/ui/preferences_window.rs index 6291fc7..ccad264 100644 --- a/src/ui/preferences_window.rs +++ b/src/ui/preferences_window.rs @@ -72,7 +72,11 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { app.font_size_input = Some(app.font_size.to_string()); } - let mut font_size_text = app.font_size_input.as_ref().unwrap_or(&"14".to_string()).clone(); + let mut font_size_text = app + .font_size_input + .as_ref() + .unwrap_or(&"14".to_string()) + .clone(); let response = ui.add( egui::TextEdit::singleline(&mut font_size_text) .desired_width(50.0) @@ -123,8 +127,10 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { }, ); ui.label( - egui::RichText::new("The quick brown fox jumps over the lazy dog.") - .font(preview_font.clone()), + egui::RichText::new( + "The quick brown fox jumps over the lazy dog.", + ) + .font(preview_font.clone()), ); ui.label( egui::RichText::new("ABCDEFGHIJKLMNOPQRSTUVWXYZ") @@ -134,7 +140,9 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { egui::RichText::new("abcdefghijklmnopqrstuvwxyz") .font(preview_font.clone()), ); - ui.label(egui::RichText::new("1234567890 !@#$%^&*()").font(preview_font)); + ui.label( + egui::RichText::new("1234567890 !@#$%^&*()").font(preview_font), + ); }); });