Update to 0.0.4 #1

Merged
candle merged 5 commits from master into release 2025-07-15 15:28:36 +00:00
15 changed files with 568 additions and 426 deletions
Showing only changes of commit 1ed06bbe37 - Show all commits

View File

@ -6,6 +6,7 @@ use super::theme::Theme;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config { pub struct Config {
pub auto_hide_toolbar: bool, pub auto_hide_toolbar: bool,
pub auto_hide_tab_bar: bool,
pub show_line_numbers: bool, pub show_line_numbers: bool,
pub word_wrap: bool, pub word_wrap: bool,
pub theme: Theme, pub theme: Theme,
@ -19,6 +20,7 @@ impl Default for Config {
fn default() -> Self { fn default() -> Self {
Self { Self {
auto_hide_toolbar: false, auto_hide_toolbar: false,
auto_hide_tab_bar: false,
show_line_numbers: false, show_line_numbers: false,
word_wrap: true, word_wrap: true,
theme: Theme::default(), theme: Theme::default(),
@ -52,7 +54,7 @@ impl Config {
if !config_path.exists() { if !config_path.exists() {
let default_config = Self::default(); let default_config = Self::default();
if let Err(e) = default_config.save() { 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; return default_config;
} }

View File

@ -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::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; use super::editor::TextEditor;
impl eframe::App for TextEditor { impl eframe::App for TextEditor {
@ -24,77 +24,8 @@ impl eframe::App for TextEditor {
menu_bar(self, ctx); menu_bar(self, ctx);
// if self.tabs.len() > 1 { if !self.auto_hide_tab_bar {
tab_bar(self, ctx); 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);
}
}
} }
central_panel(self, ctx); central_panel(self, ctx);
@ -117,6 +48,5 @@ impl eframe::App for TextEditor {
// Update the previous find state for next frame // Update the previous find state for next frame
self.prev_show_find = self.show_find; self.prev_show_find = self.show_find;
} }
} }

View File

@ -19,6 +19,7 @@ impl TextEditor {
show_line_numbers: config.show_line_numbers, show_line_numbers: config.show_line_numbers,
word_wrap: config.word_wrap, word_wrap: config.word_wrap,
auto_hide_toolbar: config.auto_hide_toolbar, auto_hide_toolbar: config.auto_hide_toolbar,
auto_hide_tab_bar: config.auto_hide_tab_bar,
theme: config.theme, theme: config.theme,
line_side: config.line_side, line_side: config.line_side,
font_family: config.font_family, font_family: config.font_family,
@ -35,17 +36,16 @@ impl TextEditor {
current_match_index: None, current_match_index: None,
case_sensitive_search: false, case_sensitive_search: false,
prev_show_find: 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, // vim_mode: config.vim_mode,
// Cursor tracking for smart scrolling // Cursor tracking for smart scrolling
previous_cursor_position: None, 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.apply_font_settings(&cc.egui_ctx);
editor.start_text_processing_thread();
editor editor
} }
@ -81,6 +79,7 @@ impl TextEditor {
Config { Config {
auto_hide_toolbar: self.auto_hide_toolbar, auto_hide_toolbar: self.auto_hide_toolbar,
show_line_numbers: self.show_line_numbers, show_line_numbers: self.show_line_numbers,
auto_hide_tab_bar: self.auto_hide_tab_bar,
word_wrap: self.word_wrap, word_wrap: self.word_wrap,
theme: self.theme, theme: self.theme,
line_side: self.line_side, line_side: self.line_side,
@ -93,7 +92,7 @@ impl TextEditor {
pub fn save_config(&self) { pub fn save_config(&self) {
let config = self.get_config(); let config = self.get_config();
if let Err(e) = config.save() { if let Err(e) = config.save() {
eprintln!("Failed to save configuration: {}", e); eprintln!("Failed to save configuration: {e}");
} }
} }
} }

View File

@ -19,6 +19,7 @@ impl Default for TextEditor {
show_line_numbers: false, show_line_numbers: false,
word_wrap: true, word_wrap: true,
auto_hide_toolbar: false, auto_hide_toolbar: false,
auto_hide_tab_bar: false,
theme: Theme::default(), theme: Theme::default(),
line_side: false, line_side: false,
font_family: "Proportional".to_string(), font_family: "Proportional".to_string(),
@ -36,17 +37,15 @@ impl Default for TextEditor {
current_match_index: None, current_match_index: None,
case_sensitive_search: false, case_sensitive_search: false,
prev_show_find: 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 // Cursor tracking for smart scrolling
previous_cursor_position: None, 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,
} }
} }
} }

View File

@ -13,18 +13,18 @@ pub enum UnsavedAction {
#[derive(Clone)] #[derive(Clone)]
pub struct TextProcessingResult { pub struct TextProcessingResult {
pub line_count: usize, pub line_count: usize,
pub visual_line_mapping: Vec<Option<usize>>, pub longest_line_index: usize, // Which line is the longest (0-based)
pub max_line_length: f32, pub longest_line_length: usize, // Character count of the longest line
pub _processed_content: String, pub longest_line_pixel_width: f32, // Actual pixel width of the longest line
} }
impl Default for TextProcessingResult { impl Default for TextProcessingResult {
fn default() -> Self { fn default() -> Self {
Self { Self {
line_count: 1, line_count: 1,
visual_line_mapping: vec![Some(1)], longest_line_index: 0,
max_line_length: 0.0, longest_line_length: 0,
_processed_content: String::new(), longest_line_pixel_width: 0.0,
} }
} }
} }
@ -44,6 +44,7 @@ pub struct TextEditor {
pub(crate) show_line_numbers: bool, pub(crate) show_line_numbers: bool,
pub(crate) word_wrap: bool, pub(crate) word_wrap: bool,
pub(crate) auto_hide_toolbar: bool, pub(crate) auto_hide_toolbar: bool,
pub(crate) auto_hide_tab_bar: bool,
pub(crate) theme: Theme, pub(crate) theme: Theme,
pub(crate) line_side: bool, pub(crate) line_side: bool,
pub(crate) font_family: String, pub(crate) font_family: String,
@ -61,15 +62,12 @@ pub struct TextEditor {
pub(crate) case_sensitive_search: bool, pub(crate) case_sensitive_search: bool,
pub(crate) prev_show_find: bool, // Track previous state to detect transitions pub(crate) prev_show_find: bool, // Track previous state to detect transitions
// Width calculation cache and state tracking
pub(crate) cached_width: Option<f32>,
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 // Cursor tracking for smart scrolling
pub(crate) previous_cursor_position: Option<usize>, pub(crate) previous_cursor_position: Option<usize>,
// Track previous content for incremental processing
pub(crate) previous_content: String,
pub(crate) previous_cursor_char_index: Option<usize>,
pub(crate) current_cursor_line: usize, // Track current line number incrementally
pub(crate) previous_cursor_line: usize, // Track previous line for comparison
} }

View File

@ -79,7 +79,7 @@ impl TextEditor {
ui.add_space(8.0); ui.add_space(8.0);
for file in &files_to_list { 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); ui.add_space(12.0);

View File

@ -1,63 +1,282 @@
use std::sync::Arc;
use std::thread;
use super::editor::{TextEditor, TextProcessingResult}; use super::editor::{TextEditor, TextProcessingResult};
use eframe::egui;
impl TextEditor { impl TextEditor {
pub fn start_text_processing_thread(&mut self) { /// Process text content and find the longest line (only used for initial scan)
let _processing_result = Arc::clone(&self.text_processing_result); 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);
let handle = thread::Builder::new() if lines.is_empty() {
.name("TextProcessor".to_string()) self.update_processing_result(TextProcessingResult {
.spawn(move || { line_count: 1,
// Set thread priority to high (platform-specific) longest_line_index: 0,
#[cfg(target_os = "linux")] longest_line_length: 0,
{ longest_line_pixel_width: 0.0,
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, &param);
}
}
}); });
return;
match handle {
Ok(h) => self.processing_thread_handle = Some(h),
Err(e) => eprintln!("Failed to start text processing thread: {}", e),
} }
}
pub fn process_text_for_rendering( // Find the longest line by character count first (fast)
&mut self, let mut longest_line_index = 0;
content: &str, let mut longest_line_length = 0;
available_width: f32,
) {
let line_count = content.lines().count().max(1);
let visual_line_mapping = if self.word_wrap { for (index, line) in lines.iter().enumerate() {
// For now, simplified mapping - this could be moved to background thread let char_count = line.chars().count();
(1..=line_count).map(Some).collect() if char_count > longest_line_length {
longest_line_length = char_count;
longest_line_index = index;
}
}
// 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 { } else {
(1..=line_count).map(Some).collect() 0.0
}; };
let result = TextProcessingResult { let result = TextProcessingResult {
line_count, line_count,
visual_line_mapping, longest_line_index,
max_line_length: available_width, longest_line_length,
_processed_content: content.to_string(), longest_line_pixel_width,
}; };
if let Ok(mut processing_result) = self.text_processing_result.lock() { self.update_processing_result(result);
*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, &current_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, &current_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, &current_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 { pub fn get_text_processing_result(&self) -> TextProcessingResult {
self.text_processing_result self.text_processing_result
.lock() .lock()
.map(|result| result.clone()) .map(|result| result.clone())
.unwrap_or_default() .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;
}
}
} }

View File

@ -12,14 +12,19 @@ impl TextEditor {
pub fn get_title(&self) -> String { pub fn get_title(&self) -> String {
if let Some(tab) = self.get_active_tab() { if let Some(tab) = self.get_active_tab() {
let modified_indicator = if tab.is_modified { "*" } else { "" }; 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 { } 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 /// 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() { let font_family = match self.font_family.as_str() {
"Monospace" => egui::FontFamily::Monospace, "Monospace" => egui::FontFamily::Monospace,
_ => egui::FontFamily::Proportional, _ => egui::FontFamily::Proportional,
@ -77,9 +82,9 @@ impl TextEditor {
// Add padding based on line_side setting // Add padding based on line_side setting
let line_number_width = if self.line_side { 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 { } 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) // Separator space (7.0 for separator + 3.0 spacing = 10.0 total)
@ -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 { pub fn calculate_content_based_width(&self, ui: &egui::Ui) -> f32 {
if let Some(active_tab) = self.get_active_tab() { let processing_result = self.get_text_processing_result();
let content = &active_tab.content;
if content.is_empty() { if processing_result.longest_line_length == 0 {
return self.calculate_editor_dimensions(ui).text_width; return self.calculate_editor_dimensions(ui).text_width;
}
// 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
} }
}
/// Check if width calculation needs to be performed based on parameter changes // Use the pre-calculated pixel width with some padding
pub fn needs_width_calculation(&self, ctx: &egui::Context) -> bool { let longest_line_width = processing_result.longest_line_pixel_width + 20.0;
let current_viewport_width = ctx.available_rect().width();
self.cached_width.is_none() || // Return the larger of the calculated width or minimum available width
self.word_wrap != self.last_word_wrap || let dimensions = self.calculate_editor_dimensions(ui);
self.show_line_numbers != self.last_show_line_numbers || longest_line_width.max(dimensions.text_width)
(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
}
/// 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<f32> {
self.cached_width
} }
} }

View File

@ -15,7 +15,7 @@ pub struct Tab {
pub file_path: Option<PathBuf>, pub file_path: Option<PathBuf>,
pub is_modified: bool, pub is_modified: bool,
pub title: String, pub title: String,
hasher: DefaultHasher, pub hasher: DefaultHasher,
} }
impl Tab { impl Tab {
@ -29,7 +29,7 @@ impl Tab {
content, content,
file_path: None, file_path: None,
is_modified: false, is_modified: false,
title: format!("new_{}", tab_number), title: format!("new_{tab_number}"),
hasher, hasher,
} }
} }

View File

@ -1,5 +1,5 @@
use crate::app::tab::Tab;
use crate::app::TextEditor; use crate::app::TextEditor;
use crate::app::tab::Tab;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@ -43,7 +43,7 @@ pub(crate) fn open_file(app: &mut TextEditor) {
} }
} }
Err(err) => { 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(); active_tab.mark_as_saved();
} }
Err(err) => { Err(err) => {
eprintln!("Failed to save file: {}", err); eprintln!("Failed to save file: {err}");
} }
} }
} }

View File

@ -11,15 +11,15 @@ fn main() -> eframe::Result {
let options = eframe::NativeOptions { let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
.with_min_inner_size([600.0, 400.0]) .with_min_inner_size([600.0, 400.0])
.with_title("C-Ext") .with_title("ced")
.with_app_id("io.lampnet.c-ext"), .with_app_id("io.lampnet.ced"),
..Default::default() ..Default::default()
}; };
let config = Config::load(); let config = Config::load();
eframe::run_native( eframe::run_native(
"C-Ext", "ced",
options, options,
Box::new(move |cc| Ok(Box::new(TextEditor::from_config_with_context(config, cc)))), Box::new(move |cc| Ok(Box::new(TextEditor::from_config_with_context(config, cc)))),
) )

View File

@ -14,7 +14,7 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
let line_side = app.line_side; let line_side = app.line_side;
let font_size = app.font_size; let font_size = app.font_size;
egui::CentralPanel::default() let output = egui::CentralPanel::default()
.frame(egui::Frame::NONE) .frame(egui::Frame::NONE)
.show(ctx, |ui| { .show(ctx, |ui| {
let bg_color = ui.visuals().extreme_bg_color; 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();
}
});
} }

View File

@ -1,28 +1,21 @@
use crate::app::TextEditor; use crate::app::TextEditor;
use eframe::egui; use eframe::egui;
use super::find_highlight::draw_find_highlight; pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::Response {
let _current_match_position = app.get_current_match_position();
pub(super) fn editor_view(
ui: &mut egui::Ui,
app: &mut TextEditor,
) -> (egui::Response, Option<egui::Rect>) {
let current_match_position = app.get_current_match_position();
let show_find = app.show_find; 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_preferences = app.show_preferences;
let show_about = app.show_about; let show_about = app.show_about;
let show_shortcuts = app.show_shortcuts; let show_shortcuts = app.show_shortcuts;
let word_wrap = app.word_wrap; let word_wrap = app.word_wrap;
let font_size = app.font_size; 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 reset_zoom_key = egui::Id::new("editor_reset_zoom");
let should_reset_zoom = ui.ctx().memory_mut(|mem| { let should_reset_zoom = ui
mem.data.get_temp::<bool>(reset_zoom_key).unwrap_or(false) .ctx()
}); .memory_mut(|mem| mem.data.get_temp::<bool>(reset_zoom_key).unwrap_or(false));
// Reset zoom if requested
if should_reset_zoom { if should_reset_zoom {
app.zoom_factor = 1.0; app.zoom_factor = 1.0;
ui.ctx().set_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 estimated_width = if !word_wrap {
let bg_color = ui.visuals().extreme_bg_color; app.calculate_content_based_width(ui)
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)
} else { } 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 Some(active_tab) = app.get_active_tab_mut() else {
let word_wrap = app.word_wrap; return ui.label("No file open, how did you get here?");
};
if word_wrap { let bg_color = ui.visuals().extreme_bg_color;
let (_response, _cursor_rect) = editor_view(ui, app); 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 { } else {
let estimated_width = app.calculate_content_based_width(ui); f32::INFINITY
let output = egui::ScrollArea::horizontal() };
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]) .auto_shrink([false; 2])
.show(ui, |ui| { .show(ui, |ui| {
ui.allocate_ui_with_layout( ui.allocate_ui_with_layout(
egui::Vec2::new(estimated_width, ui.available_height()), egui::Vec2::new(estimated_width, ui.available_height()),
egui::Layout::left_to_right(egui::Align::TOP), 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; let content_changed = output.response.changed();
if let Some(cursor_rect) = output.inner.inner.1 { let content_for_processing = if content_changed {
let text_edit_id = egui::Id::new("main_text_editor"); active_tab.update_modified_state();
let current_cursor_pos = Some(active_tab.content.clone())
if let Some(state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { } else {
state.cursor.char_range().map(|range| range.primary.index) None
} else { };
None
};
let cursor_moved = current_cursor_pos != app.previous_cursor_position; let current_cursor_pos = output
let text_changed = editor_response.changed(); .state
let should_scroll = (cursor_moved || text_changed) .cursor
&& { .char_range()
let visible_area = ui.clip_rect(); .map(|range| range.primary.index);
!visible_area.intersects(cursor_rect)
};
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
} }

View File

@ -11,12 +11,12 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
let should_show_menubar = !app.auto_hide_toolbar || { let should_show_menubar = !app.auto_hide_toolbar || {
if app.menu_interaction_active { 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 true
} else if should_stay_stable { } else if should_stay_stable {
true true
} else if let Some(pointer_pos) = ctx.pointer_hover_pos() { } 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 { if in_menu_trigger_area {
app.menu_bar_stable_until = Some(now + std::time::Duration::from_millis(300)); 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(); app.save_config();
ui.close_menu(); 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(); ui.separator();
@ -265,6 +272,41 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
ui.close_menu(); 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());
}
}); });
}); });
} }

View File

@ -27,9 +27,7 @@ fn render_shortcuts_content(ui: &mut egui::Ui) {
ui.separator(); ui.separator();
ui.label(egui::RichText::new("Views").size(18.0).strong()); 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 + L: Toggle Line Numbers").size(14.0));
ui.label( ui.label(egui::RichText::new("Ctrl + Shift + L: Change Line Number Side").size(14.0));
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 + 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 + H: Toggle Auto Hide Toolbar").size(14.0));
ui.label(egui::RichText::new("Ctrl + P: Preferences").size(14.0)); ui.label(egui::RichText::new("Ctrl + P: Preferences").size(14.0));
@ -53,8 +51,8 @@ pub(crate) fn shortcuts_window(app: &mut TextEditor, ctx: &egui::Context) {
let screen_rect = ctx.screen_rect(); let screen_rect = ctx.screen_rect();
// Calculate appropriate window size that always fits nicely in the main window // 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_width = (screen_rect.width() * 0.6).clamp(300.0, 400.0);
let window_height = (screen_rect.height() * 0.7).min(500.0).max(250.0); let window_height = (screen_rect.height() * 0.7).clamp(250.0, 500.0);
egui::Window::new("Shortcuts") egui::Window::new("Shortcuts")
.collapsible(false) .collapsible(false)