diff --git a/Cargo.toml b/Cargo.toml index 372e8b0..4818ab8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "ced" -version = "0.2.1" +version = "0.3.3" edition = "2024" [dependencies] eframe = "0.33.3" -egui = "0.33.3" +egui = "0.33.3" egui_extras = { version = "0.33.3", features = ["syntect"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.141" @@ -17,4 +17,7 @@ syntect = "5.2.0" plist = "1.7.4" diffy = "0.4.2" uuid = { version = "1.0", features = ["v4"] } -egui_commonmark = { version = "0.22" } \ No newline at end of file +egui_commonmark = { version = "0.22" } +egui_nerdfonts = "0.1.3" +vte = "0.13" +nix = { version = "0.29", features = ["term", "process", "fs"] } diff --git a/README.md b/README.md index fb26e63..a2b71bb 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ There is a disturbing lack of simple GUI text editors available on Linux nativel * Sane text editing with standard keybindings (`Ctrl+A`, `Ctrl+C`, etc.). * Choose between a fresh start each time you open, or maintaining a consistent state. +* Built-in Markdown viewer. +* Toggleable file tree. * Separate UI zoom that doesn't affect font size (`Ctrl+Shift` + `+`/`-`). * Ricers rejoice, your `pywal` colors will be used! * Weirdly smooth typing experience. @@ -22,7 +24,7 @@ There is a disturbing lack of simple GUI text editors available on Linux nativel ##### Ubuntu/Debian `sudo apt install git rust` -#### Install +### Install ```bash git clone https://code.lampnet.io/candle/ced cd ced && cargo build --release @@ -32,7 +34,7 @@ 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 +### Configuration `ced` will look for, and create if needed, a configuration file at: `$XDG_CONFIG_HOME/ced/config.toml`. @@ -45,6 +47,8 @@ show_line_numbers = false word_wrap = false theme = "System" line_side = false +show_file_tree = true +file_tree_side = false font_family = "Monospace" font_size = 16.0 syntax_highlighting = true @@ -56,10 +60,12 @@ syntax_highlighting = true |--------|---------|-------------| | `state_cache` | `false` | If `true`, opened files will remain opened with their unsaved changes when running the application again. | | `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. | -| `hide_tab_bar` | 'true' | If `false`, a separate tab bar will be drawn below the toolbar. | +| `show_tab_bar` | 'true' | If `false`, a separate tab bar will be drawn below the toolbar. | | `show_line_numbers` | `false` | If `true`, line numbers will be displayed on the side specified by `line_side`. | +| `show_file_tree` | `false` | If `true`, a file tree will be displayed on the side specified by `file_tree_side`. | | `syntax_highlighting` | `false` | If `true`, text will be highlighted based on detected language. | | `line_side` | `false` | If `false`, line numbers are on the left. If `true`, they are on the right. | +| `file_tree_side` | `false` | If `false`, file tree will appear on the lift. If `true` it will appear 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. | diff --git a/src/app/actions.rs b/src/app/actions.rs index c644eb1..396f865 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -11,6 +11,8 @@ pub enum ShortcutAction { ToggleWordWrap, ToggleAutoHideToolbar, ToggleBottomBar, + ToggleFileTree, + ToggleFileTreeSide, ToggleFind, ToggleReplace, ToggleMarkdown, @@ -27,4 +29,5 @@ pub enum ShortcutAction { Escape, Preferences, ToggleVimMode, + ToggleFocusMode, } diff --git a/src/app/config.rs b/src/app/config.rs index 6a7f4bd..140e454 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -8,15 +8,24 @@ use super::theme::Theme; pub struct Config { pub state_cache: bool, pub auto_hide_toolbar: bool, - pub hide_tab_bar: bool, - pub hide_bottom_bar: bool, + pub show_tab_bar: bool, + pub show_bottom_bar: bool, + pub show_file_tree: bool, pub show_line_numbers: bool, pub word_wrap: bool, pub theme: Theme, pub line_side: bool, + pub file_tree_side: bool, + pub show_hidden_files: bool, + pub show_terminal: bool, + pub follow_git: bool, + pub tab_char: bool, + pub tab_width: usize, pub font_family: String, pub font_size: f32, pub syntax_highlighting: bool, + pub auto_indent: bool, + pub focus_mode: bool, // pub vim_mode: bool, } @@ -25,15 +34,24 @@ impl Default for Config { Self { state_cache: false, auto_hide_toolbar: false, - hide_tab_bar: true, - hide_bottom_bar: false, + show_tab_bar: false, + show_bottom_bar: true, + show_file_tree: false, show_line_numbers: false, word_wrap: true, theme: Theme::default(), line_side: false, + file_tree_side: false, + show_hidden_files: false, + show_terminal: false, + follow_git: true, + tab_char: false, + tab_width: 4, font_family: "Proportional".to_string(), font_size: 14.0, syntax_highlighting: false, + auto_indent: true, + focus_mode: false, // vim_mode: false, } } diff --git a/src/app/shortcuts.rs b/src/app/shortcuts.rs index ec9c7be..350fd6f 100644 --- a/src/app/shortcuts.rs +++ b/src/app/shortcuts.rs @@ -28,6 +28,11 @@ fn get_shortcuts() -> Vec { egui::Key::W, ShortcutAction::CloseTab, ), + ( + egui::Modifiers::CTRL | egui::Modifiers::ALT, + egui::Key::F, + ShortcutAction::ToggleFocusMode, + ), ( egui::Modifiers::CTRL | egui::Modifiers::SHIFT, egui::Key::F, @@ -68,6 +73,16 @@ fn get_shortcuts() -> Vec { egui::Key::B, ShortcutAction::ToggleBottomBar, ), + ( + egui::Modifiers::CTRL | egui::Modifiers::SHIFT, + egui::Key::E, + ShortcutAction::ToggleFileTreeSide + ), + ( + egui::Modifiers::CTRL, + egui::Key::E, + ShortcutAction::ToggleFileTree, + ), ( egui::Modifiers::CTRL | egui::Modifiers::SHIFT, egui::Key::Tab, diff --git a/src/app/state/app_impl.rs b/src/app/state/app_impl.rs index 8625762..4fc2fad 100644 --- a/src/app/state/app_impl.rs +++ b/src/app/state/app_impl.rs @@ -3,14 +3,19 @@ use crate::app::shortcuts; use crate::ui::about_window::about_window; use crate::ui::bottom_bar::bottom_bar; use crate::ui::central_panel::central_panel; +use crate::ui::file_tree::file_tree; 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; +use crate::ui::shell_bar::shell_bar; impl eframe::App for TextEditor { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Reset focus manager at the start of each frame + self.focus_manager.reset(); + if ctx.input(|i| i.viewport().close_requested()) && !self.force_quit_confirmed && !self.clean_quit_requested @@ -22,26 +27,36 @@ impl eframe::App for TextEditor { shortcuts::handle(self, ctx); ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.get_title())); + + if !self.focus_mode { + menu_bar(self, ctx); + } - menu_bar(self, ctx); - - if !self.hide_tab_bar { + if self.show_tab_bar && !self.focus_mode { tab_bar(self, ctx); } - if !self.hide_bottom_bar { + if self.show_file_tree && !self.focus_mode { + file_tree(self, ctx); + } + + if self.show_bottom_bar && !self.focus_mode { bottom_bar(self, ctx); } + if self.show_terminal && !self.focus_mode { + shell_bar(self, ctx); + } + central_panel(self, ctx); - if self.show_about { + if self.show_about && !self.focus_mode { about_window(self, ctx); } - if self.show_shortcuts { + if self.show_shortcuts && !self.focus_mode { shortcuts_window(self, ctx); } - if self.show_preferences { + if self.show_preferences && !self.focus_mode { preferences_window(self, ctx); } if self.show_find { @@ -51,6 +66,9 @@ impl eframe::App for TextEditor { self.show_unsaved_changes_dialog(ctx); } + // Apply focus requests at the end of the frame + self.focus_manager.apply_focus(ctx); + self.prev_show_find = self.show_find; } } diff --git a/src/app/state/config.rs b/src/app/state/config.rs index 882ae18..2c65cba 100644 --- a/src/app/state/config.rs +++ b/src/app/state/config.rs @@ -11,13 +11,22 @@ impl TextEditor { show_line_numbers: config.show_line_numbers, word_wrap: config.word_wrap, auto_hide_toolbar: config.auto_hide_toolbar, - hide_tab_bar: config.hide_tab_bar, - hide_bottom_bar: config.hide_bottom_bar, + show_tab_bar: config.show_tab_bar, + show_bottom_bar: config.show_bottom_bar, + show_file_tree: config.show_file_tree, + show_terminal: config.show_terminal, theme: config.theme, line_side: config.line_side, + file_tree_side: config.file_tree_side, + show_hidden_files: config.show_hidden_files, + follow_git: config.follow_git, + tab_char: config.tab_char, + tab_width: config.tab_width, font_family: config.font_family, font_size: config.font_size, syntax_highlighting: config.syntax_highlighting, + auto_indent: config.auto_indent, + focus_mode: config.focus_mode, ..Default::default() } } @@ -32,7 +41,7 @@ impl TextEditor { if let Err(e) = editor.load_state_cache() { eprintln!("Failed to load state cache: {e}"); } - + if !initial_paths.is_empty() { let mut opened_any = false; @@ -81,6 +90,8 @@ impl TextEditor { editor.apply_font_settings(&cc.egui_ctx); + editor.previous_content = editor.get_active_tab().map(|tab| tab.content.to_owned()).unwrap_or_default(); + editor.previous_cursor_char_index = Some(0); editor } @@ -89,14 +100,23 @@ impl TextEditor { state_cache: self.state_cache, auto_hide_toolbar: self.auto_hide_toolbar, show_line_numbers: self.show_line_numbers, - hide_tab_bar: self.hide_tab_bar, - hide_bottom_bar: self.hide_bottom_bar, + show_tab_bar: self.show_tab_bar, + show_bottom_bar: self.show_bottom_bar, + show_file_tree: self.show_file_tree, + show_terminal: self.show_terminal, word_wrap: self.word_wrap, theme: self.theme, line_side: self.line_side, + file_tree_side: self.file_tree_side, + show_hidden_files: self.show_hidden_files, + follow_git: self.follow_git, + tab_char: self.tab_char, + tab_width: self.tab_width, font_family: self.font_family.to_string(), font_size: self.font_size, syntax_highlighting: self.syntax_highlighting, + auto_indent: self.auto_indent, + focus_mode: self.focus_mode, // vim_mode: self.vim_mode, } } diff --git a/src/app/state/default.rs b/src/app/state/default.rs index 3d246b3..bc74c23 100644 --- a/src/app/state/default.rs +++ b/src/app/state/default.rs @@ -1,6 +1,7 @@ use super::editor::TextEditor; use super::editor::TextProcessingResult; use crate::app::{tab::Tab, theme::Theme}; +use egui_commonmark::CommonMarkCache; use std::sync::{Arc, Mutex}; impl Default for TextEditor { @@ -21,14 +22,25 @@ impl Default for TextEditor { show_line_numbers: false, word_wrap: true, auto_hide_toolbar: false, - hide_tab_bar: true, - hide_bottom_bar: false, + show_tab_bar: false, + show_bottom_bar: true, + show_file_tree: false, + show_terminal: false, + file_tree_root: None, + file_tree_state: crate::ui::file_tree::FileTreeState::default(), syntax_highlighting: false, + auto_indent: true, theme: Theme::default(), line_side: false, + file_tree_side: false, + show_hidden_files: false, + follow_git: true, + tab_char: false, + tab_width: 4, font_family: "Proportional".to_string(), font_size: 14.0, font_size_input: None, + tab_width_input: None, zoom_factor: 1.0, menu_interaction_active: false, tab_bar_rect: None, @@ -52,6 +64,10 @@ impl Default for TextEditor { font_settings_changed: false, text_needs_processing: false, should_select_current_match: false, + markdown_cache: CommonMarkCache::default(), + focus_mode: false, + focus_manager: crate::ui::focus_manager::FocusManager::default(), + shell_state: crate::ui::shell_bar::ShellState::default(), } } } diff --git a/src/app/state/editor.rs b/src/app/state/editor.rs index 079061c..d8eb9c6 100644 --- a/src/app/state/editor.rs +++ b/src/app/state/editor.rs @@ -2,7 +2,12 @@ use crate::app::actions::ShortcutAction; use crate::app::tab::Tab; use crate::app::theme::Theme; use crate::io; +use crate::ui::file_tree::FileTreeState; +use crate::ui::focus_manager::{FocusManager, FocusTarget, priorities}; +use crate::ui::shell_bar::ShellState; use eframe::egui; +use egui_commonmark::CommonMarkCache; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::thread; @@ -48,14 +53,25 @@ pub struct TextEditor { pub(crate) show_line_numbers: bool, pub(crate) word_wrap: bool, pub(crate) auto_hide_toolbar: bool, - pub(crate) hide_tab_bar: bool, - pub(crate) hide_bottom_bar: bool, + pub(crate) show_tab_bar: bool, + pub(crate) show_bottom_bar: bool, + pub(crate) show_file_tree: bool, + pub(crate) show_terminal: bool, + pub(crate) file_tree_root: Option, + pub(crate) file_tree_state: FileTreeState, pub(crate) syntax_highlighting: bool, + pub(crate) auto_indent: bool, pub(crate) theme: Theme, pub(crate) line_side: bool, + pub(crate) file_tree_side: bool, + pub(crate) show_hidden_files: bool, + pub(crate) follow_git: bool, + pub(crate) tab_char: bool, + pub(crate) tab_width: usize, pub(crate) font_family: String, pub(crate) font_size: f32, pub(crate) font_size_input: Option, + pub(crate) tab_width_input: Option, pub(crate) zoom_factor: f32, pub(crate) menu_interaction_active: bool, pub(crate) tab_bar_rect: Option, @@ -79,6 +95,10 @@ pub struct TextEditor { pub(crate) text_needs_processing: bool, pub(crate) should_select_current_match: bool, pub(crate) previous_cursor_position: Option, + pub(crate) markdown_cache: CommonMarkCache, + pub(crate) focus_mode: bool, + pub(crate) focus_manager: FocusManager, + pub(crate) shell_state: ShellState, } impl TextEditor { @@ -138,7 +158,17 @@ impl TextEditor { false } ShortcutAction::ToggleBottomBar => { - self.hide_bottom_bar = !self.hide_bottom_bar; + self.show_bottom_bar = !self.show_bottom_bar; + self.save_config(); + false + } + ShortcutAction::ToggleFileTree => { + self.show_file_tree = !self.show_file_tree; + self.save_config(); + false + } + ShortcutAction::ToggleFileTreeSide => { + self.file_tree_side = !self.file_tree_side; self.save_config(); false } @@ -227,6 +257,11 @@ impl TextEditor { self.show_markdown = !self.show_markdown; false } + ShortcutAction::ToggleFocusMode => { + self.focus_mode = !self.focus_mode; + self.save_config(); + true + } } } } diff --git a/src/app/state/processing.rs b/src/app/state/processing.rs index 7053f29..f57708b 100644 --- a/src/app/state/processing.rs +++ b/src/app/state/processing.rs @@ -103,25 +103,6 @@ impl TextEditor { self.previous_cursor_line = self.current_cursor_line; } - fn calculate_cursor_line_change( - &self, - old_content: &str, - new_content: &str, - old_cursor_pos: usize, - new_cursor_pos: usize, - ) -> isize { - let old_newlines = safe_slice_to_pos(old_content, old_cursor_pos) - .bytes() - .filter(|&b| b == b'\n') - .count(); - - let new_newlines = safe_slice_to_pos(new_content, new_cursor_pos) - .bytes() - .filter(|&b| b == b'\n') - .count(); - - new_newlines as isize - old_newlines as isize - } fn handle_character_replacement( &mut self, @@ -150,41 +131,43 @@ impl TextEditor { 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; - for i in 0..min_len { - if old_content.as_bytes()[i] == new_content.as_bytes()[i] { - common_prefix += 1; - } else { - break; - } - } - - 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; - } - } - - let added_start = common_prefix; - let added_end = new_content.len() - common_suffix; - let added_text = &new_content[added_start..added_end]; + let old_char_count = old_content.chars().count(); + let new_char_count = new_content.chars().count(); + + let safe_new_cursor = new_cursor_pos.min(new_char_count); + + let new_byte_pos = new_content.char_indices() + .nth(safe_new_cursor) + .map(|(idx, _)| idx) + .unwrap_or(new_content.len()); + + let added_chars = new_char_count.saturating_sub(old_char_count); + let addition_start_byte = new_byte_pos.saturating_sub( + new_content[..new_byte_pos] + .chars() + .rev() + .take(added_chars) + .map(|c| c.len_utf8()) + .sum::() + ); + let addition_end_byte = new_byte_pos; + + let added_text = if addition_start_byte < addition_end_byte && addition_end_byte <= new_content.len() { + &new_content[addition_start_byte..addition_end_byte] + } else { + "" + }; let newlines_added = added_text.bytes().filter(|&b| b == b'\n').count(); if newlines_added > 0 { let mut current_result = self.get_text_processing_result(); current_result.line_count += newlines_added; - let addition_start_line = safe_slice_to_pos(old_content, added_start) + let addition_start_line = new_content[..addition_start_byte] .bytes() .filter(|&b| b == b'\n') .count(); - let addition_end_line = safe_slice_to_pos(old_content, added_end) + let addition_end_line = new_content[..addition_end_byte] .bytes() .filter(|&b| b == b'\n') .count(); @@ -200,7 +183,7 @@ impl TextEditor { self.update_processing_result(current_result); } } else { - let current_line = self.extract_current_line(new_content, new_cursor_pos); + let current_line = self.extract_current_line(new_content, safe_new_cursor); let current_line_length = current_line.chars().count(); self.update_line_if_longer( self.current_cursor_line, diff --git a/src/app/state/tabs.rs b/src/app/state/tabs.rs index 21e2e30..e004a59 100644 --- a/src/app/state/tabs.rs +++ b/src/app/state/tabs.rs @@ -36,7 +36,7 @@ impl TextEditor { self.update_find_matches(); } self.text_needs_processing = true; - + self.file_tree_state.set_selected(Some(self.get_active_tab().unwrap().file_path.clone().unwrap_or(std::path::PathBuf::new()))); if let Err(e) = self.save_state_cache() { eprintln!("Failed to save state cache: {e}"); } @@ -49,7 +49,10 @@ impl TextEditor { if self.show_find && !self.find_query.is_empty() { self.update_find_matches(); } + self.previous_content = self.get_active_tab().unwrap().content.to_owned(); + self.previous_cursor_char_index = Some(0); self.text_needs_processing = true; + self.file_tree_state.set_selected(Some(self.get_active_tab().unwrap().file_path.clone().unwrap_or(std::path::PathBuf::new()))); if let Err(e) = self.save_state_cache() { eprintln!("Failed to save state cache: {e}"); diff --git a/src/app/state/ui.rs b/src/app/state/ui.rs index 199d28e..daf29e2 100644 --- a/src/app/state/ui.rs +++ b/src/app/state/ui.rs @@ -46,6 +46,7 @@ impl TextEditor { egui::TextStyle::Monospace, egui::FontId::new(self.font_size, font_family), ); + self.font_size_input = Some(self.font_size.to_string()); ctx.set_style(style); self.font_settings_changed = true; @@ -112,11 +113,19 @@ impl TextEditor { pub fn get_cursor_position(&self) -> (usize, usize) { if let Some(active_tab) = self.get_active_tab() { let content = &active_tab.content; - let safe_pos = self.current_cursor_index.min(content.len()); + let char_count = content.chars().count(); + let safe_char_pos = self.current_cursor_index.min(char_count); + + // Convert character index to byte index + let byte_pos = content + .char_indices() + .nth(safe_char_pos) + .map(|(byte_idx, _)| byte_idx) + .unwrap_or(content.len()); // Calculate column (chars since last newline) let mut column = 0; - for c in content[..safe_pos].chars().rev() { + for c in content[..byte_pos].chars().rev() { if c == '\n' { break; } @@ -172,46 +181,64 @@ impl TextEditor { } fn move_cursor_down_lines(content: &str, current_pos: usize, lines: usize) -> usize { - let safe_pos = current_pos.min(content.len()); + let char_count = content.chars().count(); + let safe_char_pos = current_pos.min(char_count); - let mut pos = safe_pos; + // Convert character index to byte index + let byte_pos = content + .char_indices() + .nth(safe_char_pos) + .map(|(byte_idx, _)| byte_idx) + .unwrap_or(content.len()); + + let mut result_byte_pos = byte_pos; let mut lines_moved = 0; - for (idx, ch) in content[safe_pos..].char_indices() { + // char_indices() returns (byte_index, char), so idx is a byte index + for (idx, ch) in content[byte_pos..].char_indices() { if ch == '\n' { lines_moved += 1; if lines_moved >= lines { - pos = safe_pos + idx + 1; + result_byte_pos = byte_pos + idx + 1; break; } } } - if lines_moved < lines && pos == safe_pos { - pos = content.len(); + if lines_moved < lines && result_byte_pos == byte_pos { + result_byte_pos = content.len(); } - pos.min(content.len()) + // Convert byte index back to character index + content[..result_byte_pos.min(content.len())].chars().count() } fn move_cursor_up_lines(content: &str, current_pos: usize, lines: usize) -> usize { - let safe_pos = current_pos.min(content.len()); + let char_count = content.chars().count(); + let safe_char_pos = current_pos.min(char_count); - let mut pos = safe_pos; + // Convert character index to byte index + let byte_pos = content + .char_indices() + .nth(safe_char_pos) + .map(|(byte_idx, _)| byte_idx) + .unwrap_or(content.len()); + + let mut result_byte_pos = byte_pos; let mut lines_moved = 0; - for ch in content[..safe_pos].chars().rev() { - if pos > 0 { - pos -= ch.len_utf8(); - } - + // Use char_indices() and iterate in reverse to get correct byte positions + for (byte_idx, ch) in content[..byte_pos].char_indices().rev() { if ch == '\n' { lines_moved += 1; if lines_moved >= lines { + result_byte_pos = byte_idx + 1; break; } } + result_byte_pos = byte_idx; } - pos + // Convert byte index back to character index + content[..result_byte_pos].chars().count() } diff --git a/src/io.rs b/src/io.rs index ac0dd74..25cf225 100644 --- a/src/io.rs +++ b/src/io.rs @@ -250,3 +250,49 @@ pub(crate) fn save_to_path(app: &mut TextEditor, path: PathBuf) { } } } + +pub(crate) fn rename_file(app: &mut TextEditor, old_path: &PathBuf, new_name: &str) -> Result<(), String> { + let parent = old_path.parent().ok_or("Cannot rename root directory")?; + let new_path = parent.join(new_name); + + // If renaming to the same path, just return success (no-op) + if new_path == *old_path { + return Ok(()); + } + + // Check if target already exists + if new_path.exists() { + return Err(format!("File {} already exists", new_path.display())); + } + + // Rename the file on disk + fs::rename(old_path, &new_path) + .map_err(|e| format!("Failed to rename file: {}", e))?; + + // Update any tabs that reference this file + for tab in &mut app.tabs { + if let Some(tab_path) = &tab.file_path { + // Check if this tab's path matches the old path + if tab_path == old_path { + tab.file_path = Some(new_path.clone()); + tab.title = new_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Untitled") + .to_string(); + } + } + } + + // Update the active tab index if needed + if let Some(active_tab) = app.get_active_tab() { + if let Some(tab_path) = &active_tab.file_path { + if tab_path == &new_path { + // The active tab was updated, mark as needing processing + app.text_needs_processing = true; + } + } + } + + Ok(()) +} diff --git a/src/ui.rs b/src/ui.rs index b42d426..544f190 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -3,7 +3,10 @@ pub(crate) mod bottom_bar; pub(crate) mod central_panel; pub(crate) mod constants; pub(crate) mod find_window; +pub(crate) mod file_tree; +pub(crate) mod focus_manager; pub(crate) mod menu_bar; pub(crate) mod preferences_window; pub(crate) mod shortcuts_window; pub(crate) mod tab_bar; +pub(crate) mod shell_bar; \ No newline at end of file diff --git a/src/ui/bottom_bar.rs b/src/ui/bottom_bar.rs index 02ee8a9..3cca7ad 100644 --- a/src/ui/bottom_bar.rs +++ b/src/ui/bottom_bar.rs @@ -16,7 +16,7 @@ pub(crate) fn bottom_bar(app: &mut TextEditor, ctx: &egui::Context) { let file_path = active_tab.and_then(|tab| tab.file_path.as_deref()); let language = get_language_from_extension(file_path); - if !app.hide_bottom_bar { + if app.show_bottom_bar { let frame = Frame::NONE.fill(ctx.style().visuals.panel_fill); egui::TopBottomPanel::bottom("bottom_bar") .frame(frame) diff --git a/src/ui/central_panel.rs b/src/ui/central_panel.rs index 4c8effc..fb9af71 100644 --- a/src/ui/central_panel.rs +++ b/src/ui/central_panel.rs @@ -7,7 +7,6 @@ mod markdown; use crate::app::TextEditor; use crate::ui::constants::*; use eframe::egui; -use egui::UiKind; use self::editor::editor_view_ui; use self::languages::get_language_from_extension; @@ -30,6 +29,7 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { let font_id = app.get_font_id(); let show_markdown = app.show_markdown; let is_markdown_file = is_markdown_tab(app); + let focus_mode = app.focus_mode; let _output = egui::CentralPanel::default() .frame(egui::Frame::NONE) @@ -99,22 +99,15 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { return; } - if !show_line_numbers || app.get_active_tab().is_none() { + if !show_line_numbers || app.get_active_tab().is_none() || focus_mode { 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()); - - let editor_response = ui - .scope_builder(egui::UiBuilder::new().max_rect(full_rect), |ui| { - editor_view_ui(ui, app) - }) - .inner; - - handle_empty(ui, app, &context_response, &editor_response); + ui.scope_builder(egui::UiBuilder::new().max_rect(full_rect), |ui| { + editor_view_ui(ui, app); + }); }); return; } @@ -175,19 +168,10 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { egui::Layout::left_to_right(egui::Align::TOP), |ui| { let full_rect: egui::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), ); - - let editor_response = ui - .scope_builder( - egui::UiBuilder::new().max_rect(full_rect), - |ui| editor_view_ui(ui, app), - ) - .inner; - - handle_empty(ui, app, &context_response, &editor_response); }, ); separator_widget(ui); @@ -203,17 +187,10 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { separator_widget(ui); let editor_area = ui.available_rect_before_wrap(); - let context_response = - ui.allocate_response(editor_area.size(), egui::Sense::click()); - - let editor_response = ui - .scope_builder( - egui::UiBuilder::new().max_rect(editor_area), - |ui| editor_view_ui(ui, app), - ) - .inner; - - handle_empty(ui, app, &context_response, &editor_response); + ui.scope_builder( + egui::UiBuilder::new().max_rect(editor_area), + |ui| editor_view_ui(ui, app), + ); }, ); } @@ -221,80 +198,3 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { }); } -fn handle_empty( - ui: &mut egui::Ui, - app: &mut TextEditor, - context_response: &egui::Response, - editor_response: &egui::Response, -) { - if context_response.clicked() { - let text_edit_id = editor_response.id; - if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { - if let Some(active_tab) = app.get_active_tab() { - let text_len = active_tab.content.chars().count(); - let cursor_pos = egui::text::CCursor::new(text_len); - state - .cursor - .set_char_range(Some(egui::text::CCursorRange::one(cursor_pos))); - egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); - - ui.ctx().memory_mut(|mem| { - mem.request_focus(text_edit_id); - }); - } - } - } - - // Use the editor response for context menu so it captures right-clicks in the text area - editor_response.clone().context_menu(|ui| { - let text_len = app.get_active_tab().unwrap().content.chars().count(); - 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_kind(UiKind::Menu); - } - if ui.button("Copy").clicked() { - ui.ctx() - .send_viewport_cmd(egui::ViewportCommand::RequestCopy); - ui.close_kind(UiKind::Menu); - } - if ui.button("Paste").clicked() { - ui.ctx() - .send_viewport_cmd(egui::ViewportCommand::RequestPaste); - ui.close_kind(UiKind::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_kind(UiKind::Menu); - } - if ui.button("Select All").clicked() { - let text_edit_id = editor_response.id; - 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_kind(UiKind::Menu); - } - ui.separator(); - if ui.button("Reset Zoom").clicked() { - ui.ctx().memory_mut(|mem| { - mem.data.insert_temp(reset_zoom_key, true); - }); - ui.close_kind(UiKind::Menu); - } - }); -} diff --git a/src/ui/central_panel/editor.rs b/src/ui/central_panel/editor.rs index 1a3e4ea..d1fe7f6 100644 --- a/src/ui/central_panel/editor.rs +++ b/src/ui/central_panel/editor.rs @@ -1,4 +1,5 @@ use crate::app::TextEditor; +use crate::ui::focus_manager::{FocusTarget, priorities}; use eframe::egui; use egui_extras::syntax_highlighting::{self}; @@ -9,29 +10,27 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R let show_preferences = app.show_preferences; let show_about = app.show_about; let show_shortcuts = app.show_shortcuts; + let show_terminal = app.show_terminal; + let is_renaming = app + .get_active_tab() + .and_then(|tab| tab.file_path.as_ref()) + .map(|file_path| app.file_tree_state.is_renaming(file_path)) + .unwrap_or(false); let word_wrap = app.word_wrap; let font_size = app.font_size; let font_id = app.get_font_id(); let syntax_highlighting_enabled = app.syntax_highlighting; let previous_cursor_position = app.previous_cursor_position; + let auto_indent = app.auto_indent; + let tab_char = app.tab_char; + let tab_width = app.tab_width; 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 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)); - - if should_reset_zoom { - app.zoom_factor = 1.0; - ui.ctx().set_zoom_factor(1.0); - ui.ctx().memory_mut(|mem| { - mem.data.insert_temp(reset_zoom_key, false); - }); - } + handle_zoom_reset(ui, app); let (estimated_width, desired_width) = if !word_wrap { (app.calculate_content_based_width(ui), f32::INFINITY) @@ -56,56 +55,19 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R }; let draw_highlights = |ui: &mut egui::Ui| { - if let Some((content, matches, current_match_index)) = &find_data { - let temp_galley = ui.fonts_mut(|fonts| { - fonts.layout( - content.to_owned(), - font_id.to_owned(), - ui.visuals().text_color(), - desired_width - 8.0, - ) - }); - - // Use the current cursor position which handles scroll offsets correctly - let cursor_pos = ui.cursor().min; - let text_area_left = cursor_pos.x + 4.0; // Text Editor default margins - let text_area_top = cursor_pos.y + 2.0; - - find_highlight::draw_find_highlights( - ui, - content, - matches, - *current_match_index, - &temp_galley, - text_area_left, - text_area_top, - font_size, - ); - } + draw_editor_highlights(ui, &find_data, &font_id, font_size, desired_width); }; let language = super::languages::get_language_from_extension(active_tab.file_path.as_deref()); let mut layouter = |ui: &egui::Ui, string: &dyn egui::TextBuffer, wrap_width: f32| { - // let syntect_theme = - // crate::app::theme::create_code_theme_from_visuals(ui.visuals(), font_size); - let theme = egui_extras::syntax_highlighting::CodeTheme::from_style(ui.style()); - let text = string.as_str(); - let mut layout_job = if syntax_highlighting_enabled && language != "txt" { - // let mut settings = egui_extras::syntax_highlighting::SyntectSettings::default(); - // settings.ts = syntect_theme; - // syntax_highlighting::highlight_with(ui.ctx(), &ui.style().clone(), &theme, text, &language, &settings) - syntax_highlighting::highlight(ui.ctx(), &ui.style().clone(), &theme, text, &language) - } else { - syntax_highlighting::highlight(ui.ctx(), &ui.style().clone(), &theme, text, "") - }; - - if syntax_highlighting_enabled && language != "txt" { - for section in &mut layout_job.sections { - section.format.font_id = font_id.to_owned(); - } - } - - layout_job.wrap.max_width = wrap_width; + let layout_job = editor_layouter( + ui, + string, + wrap_width, + syntax_highlighting_enabled, + &language, + &font_id, + ); ui.fonts_mut(|f| f.layout_job(layout_job)) }; @@ -116,6 +78,20 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R .unwrap_or_else(|| "untitled".to_string()); let text_edit_id = egui::Id::new("main_text_editor").with(&id_source); + let mut indent_result = false; + let mut tab_result = false; + if should_have_focus( + show_find, + show_preferences, + show_about, + show_shortcuts, + is_renaming, + show_terminal, + ) { + indent_result = handle_auto_indent(ui, active_tab, text_edit_id, auto_indent); + tab_result = handle_tab_insertion(ui, active_tab, text_edit_id, tab_char, tab_width); + } + let allow_interaction = ui.is_enabled() && !ui.input(|i| { i.pointer.button_down(egui::PointerButton::Secondary) @@ -132,38 +108,6 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R .interactive(allow_interaction) .id(text_edit_id); - let ensure_cursor_visible = |ui: &mut egui::Ui, - output: &egui::text_edit::TextEditOutput, - font_id: &egui::FontId| { - let current_cursor_pos = output - .state - .cursor - .char_range() - .map(|range| range.primary.index); - - if let Some(cursor_pos) = current_cursor_pos { - let cursor_moved = Some(cursor_pos) != previous_cursor_position; - let text_changed = output.response.changed(); - - if cursor_moved || text_changed { - let cursor_rect = output - .galley - .pos_from_cursor(egui::text::CCursor::new(cursor_pos)); - - let global_cursor_rect = cursor_rect.translate(output.response.rect.min.to_vec2()); - - let line_height = ui.fonts_mut(|fonts| fonts.row_height(font_id)); - let margin = egui::vec2(40.0, line_height * 2.0); - let target_rect = global_cursor_rect.expand2(margin); - - let visible_area = ui.clip_rect(); - if !visible_area.contains_rect(target_rect) { - ui.scroll_to_rect(target_rect, Some(egui::Align::Center)); - } - } - } - }; - let output = if word_wrap { draw_highlights(ui); text_edit.show(ui) @@ -177,7 +121,7 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R |ui| { draw_highlights(ui); let output = text_edit.show(ui); - ensure_cursor_visible(ui, &output, &font_id); + ensure_cursor_visible(ui, &output, &font_id, previous_cursor_position); output }, ) @@ -186,52 +130,181 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R .inner }; - ensure_cursor_visible(ui, &output, &font_id); + ensure_cursor_visible(ui, &output, &font_id, previous_cursor_position); - let content_changed = output.response.changed(); - let content_for_processing = if content_changed { - active_tab.update_modified_state(); - Some(active_tab.content.to_owned()) - } else { - None - }; + handle_post_render_updates(ui, app, &output, indent_result, tab_result); - if content_changed && let Err(e) = app.save_state_cache() { - eprintln!("Failed to save state cache: {e}"); + output.response.context_menu(|ui| { + let text_len = app.get_active_tab().unwrap().content.chars().count(); + 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_kind(egui::UiKind::Menu); + } + if ui.button("Copy").clicked() { + ui.ctx() + .send_viewport_cmd(egui::ViewportCommand::RequestCopy); + ui.close_kind(egui::UiKind::Menu); + } + if ui.button("Paste").clicked() { + ui.ctx() + .send_viewport_cmd(egui::ViewportCommand::RequestPaste); + ui.close_kind(egui::UiKind::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_kind(egui::UiKind::Menu); + } + if ui.button("Select All").clicked() { + let text_edit_id = output.response.id; + 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_kind(egui::UiKind::Menu); + } + ui.separator(); + if ui.button("Reset Zoom").clicked() { + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(reset_zoom_key, true); + }); + ui.close_kind(egui::UiKind::Menu); + } + }); + + if !output.response.has_focus() + && should_have_focus( + show_find, + show_preferences, + show_about, + show_shortcuts, + is_renaming, + show_terminal, + ) + // Don't steal focus during file tree renaming or when terminal is showing + { + app.focus_manager + .request_focus(FocusTarget::Editor, priorities::NORMAL); } - if content_changed && app.show_find && !app.find_query.is_empty() { - app.update_find_matches(); - } + output.response +} +fn should_have_focus( + show_find: bool, + show_preferences: bool, + show_about: bool, + show_shortcuts: bool, + is_renaming: bool, + show_terminal: bool, +) -> bool { + !show_find + && !show_preferences + && !show_about + && !show_shortcuts + && !is_renaming + && !show_terminal +} + +fn handle_zoom_reset(ui: &mut egui::Ui, app: &mut TextEditor) { + 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)); + + if should_reset_zoom { + app.zoom_factor = 1.0; + ui.ctx().set_zoom_factor(1.0); + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(reset_zoom_key, false); + }); + } +} + +fn ensure_cursor_visible( + ui: &mut egui::Ui, + output: &egui::text_edit::TextEditOutput, + font_id: &egui::FontId, + previous_cursor_position: Option, +) { let current_cursor_pos = output .state .cursor .char_range() .map(|range| range.primary.index); - if let Some(content) = content_for_processing { - let previous_content = app.previous_content.to_owned(); - let previous_cursor_pos = app.previous_cursor_char_index; + if let Some(cursor_pos) = current_cursor_pos { + let cursor_moved = Some(cursor_pos) != previous_cursor_position; + let text_changed = output.response.changed(); - 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, - ); + // Check if there's an active text selection + let has_selection = output + .state + .cursor + .char_range() + .map(|range| range.primary.index != range.secondary.index) + .unwrap_or(false); + + if cursor_moved || text_changed { + let visible_area = ui.clip_rect(); + + if has_selection && output.response.dragged() { + if let Some(mouse_pos) = ui.input(|i| i.pointer.hover_pos()) { + if !visible_area.contains(mouse_pos) { + let line_height = ui.fonts_mut(|fonts| fonts.row_height(font_id)); + let margin = egui::vec2(20.0, line_height); // Smaller margin for mouse-following + let target_rect = + egui::Rect::from_center_size(mouse_pos, egui::vec2(1.0, 1.0)) + .expand2(margin); + ui.scroll_to_rect(target_rect, Some(egui::Align::Center)); + } + } + } else { + let cursor_rect = output + .galley + .pos_from_cursor(egui::text::CCursor::new(cursor_pos)); + let global_cursor_rect = cursor_rect.translate(output.response.rect.min.to_vec2()); + let line_height = ui.fonts_mut(|fonts| fonts.row_height(font_id)); + let margin = egui::vec2(40.0, line_height * 2.0); + let target_rect = global_cursor_rect.expand2(margin); + if !visible_area.contains_rect(target_rect) { + ui.scroll_to_rect(target_rect, Some(egui::Align::Center)); + } } - } else { - app.process_text_for_rendering(&content, ui); } + } +} - app.previous_content = content.to_owned(); - app.previous_cursor_char_index = current_cursor_pos; +fn handle_post_render_updates( + ui: &mut egui::Ui, + app: &mut TextEditor, + output: &egui::text_edit::TextEditOutput, + indent_result: bool, + tab_result: bool, +) { + let content_changed = output.response.changed() || indent_result || tab_result; + let current_cursor_pos = output + .state + .cursor + .char_range() + .map(|range| range.primary.index); + + if content_changed { + handle_content_change(app, current_cursor_pos, ui); } if app.font_settings_changed || app.text_needs_processing { @@ -246,26 +319,253 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R if let Some(cursor_pos) = current_cursor_pos { app.previous_cursor_position = Some(cursor_pos); app.current_cursor_index = cursor_pos; + update_cursor_line_info(app, cursor_pos); + } +} - // Calculate line and column - if let Some(active_tab) = app.get_active_tab() { - let content = &active_tab.content; - let safe_pos = cursor_pos.min(content.len()); +fn handle_content_change( + app: &mut TextEditor, + current_cursor_pos: Option, + ui: &mut egui::Ui, +) { + let Some(active_tab) = app.get_active_tab_mut() else { + return; + }; + active_tab.update_modified_state(); + let content = active_tab.content.to_owned(); - // Count newlines before cursor for line number - let line_number = content[..safe_pos].chars().filter(|&c| c == '\n').count() + 1; - app.current_cursor_line = line_number; + if let Err(e) = app.save_state_cache() { + eprintln!("Failed to save state cache: {e}"); + } + + if app.show_find && !app.find_query.is_empty() { + app.update_find_matches(); + } + + let previous_content = app.previous_content.to_owned(); + let previous_cursor_pos = app.previous_cursor_char_index; + + if !previous_content.is_empty() + && 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); + } + + app.previous_content = content; + app.previous_cursor_char_index = current_cursor_pos; +} + +fn update_cursor_line_info(app: &mut TextEditor, cursor_pos: usize) { + if let Some(active_tab) = app.get_active_tab() { + let content = &active_tab.content; + let char_count = content.chars().count(); + let safe_char_pos = cursor_pos.min(char_count); + + // Convert character index to byte index + let byte_pos = content + .char_indices() + .nth(safe_char_pos) + .map(|(byte_idx, _)| byte_idx) + .unwrap_or(content.len()); + + // Count newlines before cursor for line number + let line_number = content[..byte_pos].chars().filter(|&c| c == '\n').count() + 1; + app.current_cursor_line = line_number; + } +} + +fn draw_editor_highlights( + ui: &mut egui::Ui, + find_data: &Option<(String, Vec<(usize, usize)>, Option)>, + font_id: &egui::FontId, + font_size: f32, + wrap_width: f32, +) { + if let Some((content, matches, current_match_index)) = find_data { + let temp_galley = ui.fonts_mut(|fonts| { + fonts.layout( + content.to_owned(), + font_id.to_owned(), + ui.visuals().text_color(), + wrap_width - 8.0, + ) + }); + + let cursor_pos = ui.cursor().min; + let text_area_left = cursor_pos.x + 4.0; // Text Editor default margins + let text_area_top = cursor_pos.y + 2.0; + + find_highlight::draw_find_highlights( + ui, + content, + matches, + *current_match_index, + &temp_galley, + text_area_left, + text_area_top, + font_size, + ); + } +} + +fn editor_layouter( + ui: &egui::Ui, + string: &dyn egui::TextBuffer, + wrap_width: f32, + syntax_highlighting_enabled: bool, + language: &str, + font_id: &egui::FontId, +) -> egui::text::LayoutJob { + let theme = egui_extras::syntax_highlighting::CodeTheme::from_style(ui.style()); + let text = string.as_str(); + + let mut layout_job = if syntax_highlighting_enabled && language != "txt" { + syntax_highlighting::highlight(ui.ctx(), &ui.style().clone(), &theme, text, language) + } else { + syntax_highlighting::highlight(ui.ctx(), &ui.style().clone(), &theme, text, "") + }; + + if syntax_highlighting_enabled && language != "txt" { + for section in &mut layout_job.sections { + section.format.font_id = font_id.to_owned(); } } - if !output.response.has_focus() - && !show_preferences - && !show_about - && !show_shortcuts - && !show_find - { - output.response.request_focus(); + layout_job.wrap.max_width = wrap_width; + layout_job +} + +fn handle_auto_indent( + ui: &mut egui::Ui, + active_tab: &mut crate::app::tab::Tab, + text_edit_id: egui::Id, + auto_indent: bool, +) -> bool { + if !auto_indent || !ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.is_none()) { + return false; } - output.response + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { + if let Some(cursor_range) = state.cursor.char_range() { + let cursor_pos = cursor_range.primary.index; + let content = &active_tab.content; + + // Find previous line's indentation + let byte_idx = content + .char_indices() + .nth(cursor_pos) + .map(|(i, _)| i) + .unwrap_or(content.len()); + let before_cursor = &content[..byte_idx]; + let line_start = before_cursor.rfind('\n').map(|i| i + 1).unwrap_or(0); + let current_line = &before_cursor[line_start..]; + let indentation: String = current_line + .chars() + .take_while(|&c| c == ' ' || c == '\t') + .collect(); + + // Replace selection (or just insert at cursor) with newline + indentation + let start_char = cursor_range.primary.index.min(cursor_range.secondary.index); + let end_char = cursor_range.primary.index.max(cursor_range.secondary.index); + + let start_byte = content + .char_indices() + .nth(start_char) + .map(|(i, _)| i) + .unwrap_or(content.len()); + let end_byte = content + .char_indices() + .nth(end_char) + .map(|(i, _)| i) + .unwrap_or(content.len()); + + let insert_text = format!("\n{}", indentation); + active_tab + .content + .replace_range(start_byte..end_byte, &insert_text); + + // Update cursor position + let new_pos = start_char + insert_text.chars().count(); + state + .cursor + .set_char_range(Some(egui::text::CCursorRange::one( + egui::text::CCursor::new(new_pos), + ))); + egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); + + // Consume the event so TextEdit doesn't process it + ui.input_mut(|i| { + i.events.retain(|e| { + !matches!( + e, + egui::Event::Key { + key: egui::Key::Enter, + pressed: true, + .. + } + ) + }) + }); + + return true; + } + } + false +} + +fn handle_tab_insertion( + ui: &mut egui::Ui, + active_tab: &mut crate::app::tab::Tab, + text_edit_id: egui::Id, + tab_char: bool, + tab_width: usize, +) -> bool { + if tab_char || !ui.input(|i| i.key_pressed(egui::Key::Tab) && i.modifiers.is_none()) { + return false; + } + + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { + if let Some(cursor_range) = state.cursor.char_range() { + let cursor_pos = cursor_range.primary.index; + let content = &active_tab.content; + let byte_idx = content + .char_indices() + .nth(cursor_pos) + .map(|(i, _)| i) + .unwrap_or(content.len()); + let insert_text = " ".repeat(tab_width); + active_tab + .content + .replace_range(byte_idx..byte_idx, &insert_text); + let new_pos = cursor_pos + insert_text.chars().count(); + state + .cursor + .set_char_range(Some(egui::text::CCursorRange::one( + egui::text::CCursor::new(new_pos), + ))); + egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); + ui.input_mut(|i| { + i.events.retain(|e| { + !matches!( + e, + egui::Event::Key { + key: egui::Key::Tab, + pressed: true, + .. + } + ) + }) + }); + } + } + true } diff --git a/src/ui/central_panel/markdown.rs b/src/ui/central_panel/markdown.rs index 0886f43..008f3c0 100644 --- a/src/ui/central_panel/markdown.rs +++ b/src/ui/central_panel/markdown.rs @@ -1,14 +1,14 @@ use crate::app::TextEditor; use eframe::egui; -use egui_commonmark::{CommonMarkViewer, CommonMarkCache}; +use egui_commonmark::CommonMarkViewer; pub(super) fn markdown_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) { - let Some(active_tab) = app.get_active_tab() else { + let content = if let Some(active_tab) = app.get_active_tab() { + active_tab.content.clone() + } else { ui.label("No file open"); return; }; - // Properly render Markdown content using CommonMarkViewer - let mut cache = CommonMarkCache::default(); - CommonMarkViewer::new().show(ui, &mut cache, &active_tab.content); + CommonMarkViewer::new().show(ui, &mut app.markdown_cache, &content); } \ No newline at end of file diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs new file mode 100644 index 0000000..b9a695e --- /dev/null +++ b/src/ui/file_tree.rs @@ -0,0 +1,932 @@ +use crate::app::TextEditor; +use crate::ui::focus_manager::{FocusTarget, priorities}; +use egui::Ui; +use std::fs; +use std::io::BufRead; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Default)] +pub struct FileTreeState { + expanded_paths: std::collections::HashSet, + selected_path: Option, + renaming_path: Option, + rename_text: String, + clipboard_paths: Vec, + clipboard_operation: Option, + rename_focus_requested: bool, +} + +#[derive(Clone, Copy, Debug)] +pub enum ClipboardOperation { + Cut, + Copy, +} + +impl FileTreeState { + pub fn is_expanded(&self, path: &Path) -> bool { + self.expanded_paths.contains(path) + } + + pub fn toggle_expand(&mut self, path: &Path) { + if self.expanded_paths.contains(path) { + self.expanded_paths.remove(path); + } else { + self.expanded_paths.insert(path.to_path_buf()); + } + } + + pub fn is_selected(&self, path: &Path) -> bool { + self.selected_path.as_ref().map_or(false, |p| p == path) + } + + pub fn set_selected(&mut self, path: Option) { + self.selected_path = path; + } + + pub fn is_renaming(&self, path: &Path) -> bool { + self.renaming_path.as_ref().map_or(false, |p| p == path) + } + + pub fn start_rename(&mut self, path: &Path, initial_name: &str) { + self.renaming_path = Some(path.to_path_buf()); + self.rename_text = initial_name.to_string(); + self.rename_focus_requested = false; + } + + pub fn cancel_rename(&mut self) { + self.renaming_path = None; + self.rename_text.clear(); + self.rename_focus_requested = false; + } + + pub fn finish_rename(&mut self) -> Option<(PathBuf, String)> { + if let Some(old_path) = self.renaming_path.take() { + let new_name = self.rename_text.clone(); + self.rename_text.clear(); + self.rename_focus_requested = false; + Some((old_path, new_name)) + } else { + None + } + } + + pub fn set_clipboard(&mut self, paths: Vec, operation: ClipboardOperation) { + self.clipboard_paths = paths; + self.clipboard_operation = Some(operation); + } + + pub fn clear_clipboard(&mut self) { + self.clipboard_paths.clear(); + self.clipboard_operation = None; + } + + pub fn get_clipboard(&self) -> Option<(&Vec, &ClipboardOperation)> { + self.clipboard_operation + .as_ref() + .map(|op| (&self.clipboard_paths, op)) + } +} + +fn draw_tree_lines(ui: &mut Ui, depth: usize, is_last: bool, extend_to_icon: bool) { + if depth == 0 { + return; + } + + let line_height = ui.text_style_height(&egui::TextStyle::Body); + let char_width = 8.0; + let base_width = (depth as f32 * 3.0 * char_width).ceil(); + + let (rect, _) = if extend_to_icon { + ui.allocate_at_least( + egui::vec2(base_width + 16.0, line_height), + egui::Sense::hover(), + ) + } else { + ui.allocate_at_least(egui::vec2(base_width, line_height), egui::Sense::hover()) + }; + + let painter = ui.painter(); + let stroke = egui::Stroke::new(1.0, egui::Color32::from_gray(128)); + + let mut x_offset = rect.left(); + + for i in 0..depth { + if i == depth - 1 { + let line_x = x_offset + char_width * 1.5; + + if is_last { + painter.line_segment( + [ + egui::pos2(line_x, rect.top() - line_height * 0.5), + egui::pos2(line_x, rect.bottom() - line_height * 0.5), + ], + stroke, + ); + let end_x = if extend_to_icon { + rect.right() + char_width + } else { + x_offset + char_width * 4.0 + }; + painter.line_segment( + [ + egui::pos2(line_x, rect.bottom() - line_height * 0.5), + egui::pos2(end_x, rect.bottom() - line_height * 0.5), + ], + stroke, + ); + } else { + painter.line_segment( + [ + egui::pos2(line_x, rect.top() - line_height * 0.5), + egui::pos2(line_x, rect.bottom() + line_height * 0.5), + ], + stroke, + ); + let end_x = if extend_to_icon { + rect.right() + char_width + } else { + x_offset + char_width * 4.0 + }; + painter.line_segment( + [ + egui::pos2(line_x, rect.bottom() - line_height * 0.5), + egui::pos2(end_x, rect.bottom() - line_height * 0.5), + ], + stroke, + ); + } + } else { + let line_x = x_offset + char_width * 1.5; + painter.line_segment( + [ + egui::pos2(line_x, rect.top() - line_height * 0.5), + egui::pos2(line_x, rect.bottom() + line_height * 0.5), + ], + stroke, + ); + } + x_offset += char_width * 3.0; + } +} + +fn is_text_file(path: &Path) -> bool { + let language = crate::ui::central_panel::languages::get_language_from_extension(Some(path)); + + if language != "txt" { + true + } else if let Some(extension) = path.extension().and_then(|s| s.to_str()) { + matches!( + extension.to_lowercase().as_str(), + "txt" | "gitignore" | "conf" | "cfg" | "ini" | "log" | "csv" | "tsv" + ) + } else { + if let Ok(metadata) = fs::metadata(path) { + metadata.len() < 1024 * 1024 + } else { + false + } + } +} + +fn should_show_entry(path: &Path, app: &TextEditor, gitignore_patterns: &[String]) -> bool { + let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + // Check if it's a hidden file + if !app.show_hidden_files && file_name.starts_with('.') { + return false; + } + + // Check if it matches gitignore patterns + if app.follow_git && !gitignore_patterns.is_empty() { + let relative_path = path + .strip_prefix(&app.file_tree_root.as_ref().unwrap_or(&PathBuf::from("/"))) + .unwrap_or(path); + + for pattern in gitignore_patterns { + if matches_gitignore_pattern(relative_path, pattern) { + return false; + } + } + } + + true +} + +fn load_gitignore_patterns(root_path: &Path) -> Vec { + let gitignore_path = root_path.join(".gitignore"); + if !gitignore_path.exists() { + return Vec::new(); + } + + let file = match fs::File::open(&gitignore_path) { + Ok(file) => file, + Err(_) => return Vec::new(), + }; + + let reader = std::io::BufReader::new(file); + reader + .lines() + .filter_map(Result::ok) + .filter(|line| { + let trimmed = line.trim(); + !trimmed.is_empty() && !trimmed.starts_with('#') + }) + .collect() +} + +fn matches_gitignore_pattern(path: &Path, pattern: &str) -> bool { + let path_str = path.to_string_lossy(); + let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + // Remove leading slashes from pattern + let pattern = pattern.trim_start_matches('/'); + + // Handle directory patterns (ending with /) + if pattern.ends_with('/') { + let dir_pattern = &pattern[..pattern.len() - 1]; + return path.is_dir() && matches_glob_pattern(&path_str, dir_pattern); + } + + // Handle wildcard patterns + if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') { + return matches_glob_pattern(&path_str, pattern) + || matches_glob_pattern(file_name, pattern); + } + + // Exact match + path_str == pattern || file_name == pattern +} + +fn matches_glob_pattern(text: &str, pattern: &str) -> bool { + // Split pattern by * to handle simple cases + let parts: Vec<&str> = pattern.split('*').collect(); + + if parts.len() == 1 { + // No wildcards, exact match + return text == pattern; + } + + if pattern == "*" { + // Match anything + return true; + } + + if pattern.starts_with('*') && pattern.ends_with('*') && parts.len() == 3 && parts[1].is_empty() + { + // Pattern like "*text*" - contains match + return text.contains(parts[0]); + } + + if pattern.starts_with('*') && !pattern.ends_with('*') { + // Pattern like "*suffix" - ends with match + return text.ends_with(&pattern[1..]); + } + + if !pattern.starts_with('*') && pattern.ends_with('*') { + // Pattern like "prefix*" - starts with match + return text.starts_with(&pattern[..pattern.len() - 1]); + } + + // More complex patterns - use a simple state machine + match_complex_glob(text, pattern) +} + +fn match_complex_glob(text: &str, pattern: &str) -> bool { + let text_bytes = text.as_bytes(); + let pattern_bytes = pattern.as_bytes(); + + let mut text_pos = 0; + let mut pattern_pos = 0; + let mut star_pos = None; + + while text_pos < text_bytes.len() { + if pattern_pos < pattern_bytes.len() + && (pattern_bytes[pattern_pos] == text_bytes[text_pos] + || pattern_bytes[pattern_pos] == b'?') + { + text_pos += 1; + pattern_pos += 1; + } else if pattern_pos < pattern_bytes.len() && pattern_bytes[pattern_pos] == b'*' { + star_pos = Some((text_pos, pattern_pos)); + pattern_pos += 1; + } else if let Some((saved_text_pos, saved_pattern_pos)) = star_pos { + // Try to advance text position and retry + star_pos = Some((saved_text_pos + 1, saved_pattern_pos)); + text_pos = saved_text_pos + 1; + pattern_pos = saved_pattern_pos + 1; + } else { + return false; + } + } + + // Skip remaining wildcards + while pattern_pos < pattern_bytes.len() && pattern_bytes[pattern_pos] == b'*' { + pattern_pos += 1; + } + + pattern_pos == pattern_bytes.len() +} + +fn show_directory_context_menu( + ui: &mut Ui, + path: &Path, + app: &mut TextEditor, + ctx: &egui::Context, +) { + ui.menu_button("New", |ui| { + if ui.button("File").clicked() { + create_new_file_at_path(path, app, ctx); + ui.close(); + } + if ui.button("Directory").clicked() { + create_new_directory_at_path(path, app, ctx); + ui.close(); + } + }); + + ui.separator(); + + if ui.button("Cut").clicked() { + app.file_tree_state + .set_clipboard(vec![path.to_path_buf()], ClipboardOperation::Cut); + ui.close(); + } + + if ui.button("Copy").clicked() { + app.file_tree_state + .set_clipboard(vec![path.to_path_buf()], ClipboardOperation::Copy); + ui.close(); + } + + if ui.button("Paste").clicked() { + if let Some((paths, operation)) = app.file_tree_state.get_clipboard() { + paste_items(paths.clone(), path, *operation, app, ctx); + } + ui.close(); + } + + ui.separator(); + + if ui.button("Delete").clicked() { + delete_path(path, app, ctx); + ui.close(); + } +} + +fn show_file_context_menu(ui: &mut Ui, path: &Path, app: &mut TextEditor, ctx: &egui::Context) { + ui.menu_button("New", |ui| { + if ui.button("File").clicked() { + if let Some(parent) = path.parent() { + create_new_file_at_path(parent, app, ctx); + } + ui.close(); + } + if ui.button("Directory").clicked() { + if let Some(parent) = path.parent() { + create_new_directory_at_path(parent, app, ctx); + } + ui.close(); + } + }); + + ui.separator(); + + if ui.button("Cut").clicked() { + app.file_tree_state + .set_clipboard(vec![path.to_path_buf()], ClipboardOperation::Cut); + ui.close(); + } + + if ui.button("Copy").clicked() { + app.file_tree_state + .set_clipboard(vec![path.to_path_buf()], ClipboardOperation::Copy); + ui.close(); + } + + if let Some(parent) = path.parent() { + if ui.button("Paste").clicked() { + if let Some((paths, operation)) = app.file_tree_state.get_clipboard() { + paste_items(paths.clone(), parent, *operation, app, ctx); + } + ui.close(); + } + } + + ui.separator(); + + if ui.button("Delete").clicked() { + delete_path(path, app, ctx); + ui.close(); + } + + if ui.button("Rename").clicked() { + app.file_tree_state.start_rename( + path, + path.file_name() + .unwrap_or_default() + .to_string_lossy() + .as_ref(), + ); + ui.close(); + } +} + +fn find_tab_index_by_path(path: &Path, app: &TextEditor) -> Option { + app.tabs.iter().position(|tab| { + tab.file_path.as_ref().map_or(false, |tab_path| { + if tab_path == path { + return true; + } + match (tab_path.canonicalize(), path.canonicalize()) { + (Ok(canonical_tab), Ok(canonical_path)) => canonical_tab == canonical_path, + _ => false, + } + }) + }) +} + +fn create_new_file_at_path(parent_path: &Path, app: &mut TextEditor, ctx: &egui::Context) { + let mut counter = 1; + let mut new_file_path = parent_path.join(format!("new_file_{}.txt", counter)); + + while new_file_path.exists() { + counter += 1; + new_file_path = parent_path.join(format!("new_file_{}.txt", counter)); + } + + match fs::File::create(&new_file_path) { + Ok(_) => { + app.state_cache = true; + // Start rename mode for the newly created file + app.file_tree_state.start_rename(&new_file_path, ""); + ctx.request_repaint(); + } + Err(e) => { + eprintln!("Failed to create new file: {}", e); + } + } +} + +fn create_new_directory_at_path(parent_path: &Path, app: &mut TextEditor, ctx: &egui::Context) { + let mut counter = 1; + let mut new_dir_path = parent_path.join(format!("new_directory_{}", counter)); + + while new_dir_path.exists() { + counter += 1; + new_dir_path = parent_path.join(format!("new_directory_{}", counter)); + } + + match fs::create_dir(&new_dir_path) { + Ok(_) => { + app.state_cache = true; + // Start rename mode for the newly created directory + app.file_tree_state.start_rename(&new_dir_path, ""); + ctx.request_repaint(); + } + Err(e) => { + eprintln!("Failed to create new directory: {}", e); + } + } +} + +fn paste_items( + source_paths: Vec, + destination_path: &Path, + operation: ClipboardOperation, + app: &mut TextEditor, + ctx: &egui::Context, +) { + for source_path in source_paths { + let file_name = source_path.file_name().unwrap_or_default(); + let mut target_path = destination_path.join(file_name); + let mut counter = 1; + + // Handle name conflicts + while target_path.exists() { + let stem = source_path + .file_stem() + .unwrap_or_default() + .to_string_lossy(); + let extension = source_path + .extension() + .unwrap_or_default() + .to_string_lossy(); + + let new_name = if extension.is_empty() { + format!("{}_copy_{}", stem, counter) + } else { + format!("{}_copy_{}.{}", stem, counter, extension) + }; + + target_path = destination_path.join(new_name); + counter += 1; + } + + let result = match operation { + ClipboardOperation::Copy => { + if source_path.is_file() { + fs::copy(&source_path, &target_path).map(|_| ()) + } else { + copy_directory_recursive(&source_path, &target_path) + } + } + ClipboardOperation::Cut => fs::rename(&source_path, &target_path), + }; + + if let Err(e) = result { + eprintln!( + "Failed to {} {} to {}: {}", + match operation { + ClipboardOperation::Copy => "copy", + ClipboardOperation::Cut => "move", + }, + source_path.display(), + target_path.display(), + e + ); + } + } + + app.file_tree_state.clear_clipboard(); + app.state_cache = true; + ctx.request_repaint(); +} + +fn copy_directory_recursive(source: &Path, destination: &Path) -> Result<(), std::io::Error> { + fs::create_dir(destination)?; + + for entry in fs::read_dir(source)? { + let entry = entry?; + let entry_path = entry.path(); + let dest_path = destination.join(entry.file_name()); + + if entry_path.is_dir() { + copy_directory_recursive(&entry_path, &dest_path)?; + } else { + fs::copy(&entry_path, &dest_path)?; + } + } + + Ok(()) +} + +fn delete_path(path: &Path, app: &mut TextEditor, ctx: &egui::Context) { + // Close any open tabs for this file + if let Some(tab_index) = find_tab_index_by_path(path, app) { + app.tabs.remove(tab_index); + if app.active_tab_index >= app.tabs.len() && app.active_tab_index > 0 { + app.active_tab_index -= 1; + } + } + + let result = if path.is_file() { + fs::remove_file(path) + } else { + fs::remove_dir_all(path) + }; + + if let Err(e) = result { + eprintln!("Failed to delete {}: {}", path.display(), e); + } else { + app.state_cache = true; + ctx.request_repaint(); + } +} + +fn is_file_opened(path: &Path, app: &TextEditor) -> bool { + find_tab_index_by_path(path, app).is_some() +} + +fn display_directory( + ui: &mut Ui, + path: &Path, + depth: usize, + is_last: bool, + app: &mut TextEditor, + ctx: &egui::Context, +) -> Option { + let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + let mut clicked_path = None; + + let is_expanded = app.file_tree_state.is_expanded(path); + let is_renaming = app.file_tree_state.is_renaming(path); + let has_opened_files = app.tabs.iter().any(|tab| { + tab.file_path.as_ref().map_or(false, |tab_path| { + match (tab_path.canonicalize(), path.canonicalize()) { + (Ok(canonical_tab), Ok(canonical_dir)) => { + canonical_tab.starts_with(&canonical_dir) && canonical_tab != canonical_dir + } + _ => tab_path.starts_with(path) && tab_path != path, + } + }) + }); + + ui.horizontal(|ui| { + draw_tree_lines(ui, depth, is_last, false); + if is_renaming { + let text_edit = egui::TextEdit::singleline(&mut app.file_tree_state.rename_text) + .desired_width(100.0) + .hint_text(dir_name); + + let response = ui.add(text_edit); + + if ui.input(|i| i.key_pressed(egui::Key::Enter)) { + if let Some((old_path, new_name)) = app.file_tree_state.finish_rename() { + if let Err(e) = crate::io::rename_file(app, &old_path, &new_name) { + eprintln!("Failed to rename directory: {}", e); + } + ctx.request_repaint(); + } + } else if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.file_tree_state.cancel_rename(); + ctx.request_repaint(); + } + + // Request focus for the rename text edit if we haven't already + if !app.file_tree_state.rename_focus_requested { + app.focus_manager + .request_focus(FocusTarget::FileTreeRename, priorities::HIGH); + app.file_tree_state.rename_focus_requested = true; + } + } else { + let text_color = if has_opened_files { + ui.visuals().warn_fg_color + //egui::Color32::from_rgb(100, 200, 255) // Light blue for directories with opened files + } else { + ui.visuals().text_color() + }; + + let icon = if is_expanded { "πŸ“‚" } else { "πŸ“" }; + let display_text = format!("{} {}", icon, dir_name); + + let response = ui.selectable_label( + false, + egui::RichText::new(display_text).strong().color(text_color), + ); + + if response.clicked() { + app.file_tree_state.toggle_expand(path); + } + + response.context_menu(|ui| { + show_directory_context_menu(ui, path, app, ctx); + }); + } + }); + + if is_expanded { + if let Ok(entries) = fs::read_dir(path) { + // Load gitignore patterns if follow_git is enabled + let gitignore_patterns = if app.follow_git && path.join(".git").exists() { + load_gitignore_patterns(path) + } else { + Vec::new() + }; + + let mut entries: Vec<_> = entries + .filter_map(Result::ok) + .filter(|entry| should_show_entry(&entry.path(), app, &gitignore_patterns)) + .collect(); + + entries.sort_by(|a, b| { + let a_is_dir = a.path().is_dir(); + let b_is_dir = b.path().is_dir(); + if a_is_dir == b_is_dir { + a.file_name().cmp(&b.file_name()) + } else if a_is_dir { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Greater + } + }); + + let total_entries = entries.len(); + for (index, entry) in entries.iter().enumerate() { + let entry_path = entry.path(); + let is_last_entry = index == total_entries - 1; + + if entry_path.is_dir() { + if let Some(clicked) = + display_directory(ui, &entry_path, depth + 1, is_last_entry, app, ctx) + { + clicked_path = Some(clicked); + } + } else if is_text_file(&entry_path) { + if let Some(clicked) = + display_file(ui, &entry_path, depth + 1, is_last_entry, app, ctx) + { + clicked_path = Some(clicked); + } + } else { + display_file(ui, &entry_path, depth + 1, is_last_entry, app, ctx); + } + } + } + } + + clicked_path +} + +fn display_file( + ui: &mut Ui, + path: &Path, + depth: usize, + is_last: bool, + app: &mut TextEditor, + ctx: &egui::Context, +) -> Option { + let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + let is_selected = app.file_tree_state.is_selected(path); + let is_opened = is_file_opened(path, app); + let is_renaming = app.file_tree_state.is_renaming(path); + + let mut clicked_path = None; + + ui.horizontal(|ui| { + draw_tree_lines(ui, depth, is_last, true); + + if is_renaming { + let text_edit = egui::TextEdit::singleline(&mut app.file_tree_state.rename_text) + .desired_width(100.0) + .hint_text(file_name); + + let response = ui.add(text_edit); + + if ui.input(|i| i.key_pressed(egui::Key::Enter)) { + if let Some((old_path, new_name)) = app.file_tree_state.finish_rename() { + if let Err(e) = crate::io::rename_file(app, &old_path, &new_name) { + eprintln!("Failed to rename file: {}", e); + } + ctx.request_repaint(); + } + } else if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.file_tree_state.cancel_rename(); + ctx.request_repaint(); + } + + // Request focus for the rename text edit if we haven't already + if !app.file_tree_state.rename_focus_requested { + app.focus_manager + .request_focus(FocusTarget::FileTreeRename, priorities::HIGH); + app.file_tree_state.rename_focus_requested = true; + } + } else { + let text_color = if is_selected { + ui.visuals().error_fg_color + } else if is_opened { + ui.visuals().warn_fg_color + } else { + ui.visuals().text_color() + }; + // let icon = get_nerd_font_icon(path.extension().and_then(|s| s.to_str()).unwrap_or(""), path); + let icon = "πŸ“„"; + let display_text = format!("{} {}", icon, file_name); + let response = + ui.selectable_label(false, egui::RichText::new(display_text).color(text_color)); + + if response.clicked() { + if let Some(tab_index) = find_tab_index_by_path(path, app) { + app.switch_to_tab(tab_index); + app.file_tree_state.set_selected(Some(path.to_path_buf())); + clicked_path = Some(path.to_path_buf()); + app.file_tree_state.cancel_rename(); + ctx.request_repaint(); + } + } + if response.double_clicked() { + if is_opened { + app.file_tree_state.start_rename(path, file_name); + } else { + if let Err(e) = crate::io::open_file_from_path(app, path.to_path_buf()) { + eprintln!("Failed to open file: {}", e); + } + app.file_tree_state.set_selected(Some(path.to_path_buf())); + clicked_path = Some(path.to_path_buf()); + } + ctx.request_repaint(); + } + + response.context_menu(|ui| { + show_file_context_menu(ui, path, app, ctx); + }); + } + }); + + clicked_path +} + +pub(crate) fn file_tree(app: &mut TextEditor, ctx: &egui::Context) { + let panel = if app.file_tree_side { + egui::SidePanel::right("file_tree") + } else { + egui::SidePanel::left("file_tree") + }; + + panel + .resizable(true) + .default_width(150.0) + .show_animated(ctx, app.show_file_tree, |ui| { + ui.horizontal_top(|ui| { + if ui.button("πŸ“").clicked() { + if let Some(path) = rfd::FileDialog::new() + .set_directory(std::env::current_dir().unwrap_or_default()) + .pick_folder() + { + app.file_tree_root = Some(path.clone()); + app.state_cache = true; + } + } + }); + ui.separator(); + + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + let display_dir = app + .file_tree_root + .clone() + .filter(|path| path.exists()) + .or_else(|| std::env::current_dir().ok().filter(|path| path.exists())) + .or_else(|| { + std::env::var("HOME") + .ok() + .map(PathBuf::from) + .filter(|path| path.exists()) + }); + + if let Some(dir) = display_dir { + display_directory(ui, &dir, 0, true, app, ctx); + } else { + ui.label("Failed to get current directory or home directory"); + } + }); + }); +} + +pub fn get_nerd_font_icon(extension: &str, path: &Path) -> &'static str { + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + + match filename.to_lowercase().as_str() { + "license" | "license.md" | "license.txt" => "", // License file + "dockerfile" => "", // Docker + "makefile" => "ξ˜•", // Makefile + "readme" | "readme.md" => "ξΆ€", // README + ".gitignore" => "ξ˜•", // Git + "cargo.toml" => "", // Rust/Cargo + "package.json" => "", // Node/npm + "cargo.lock" => "ο€£", // Cargo lock + _ => match extension.to_lowercase().as_str() { + "rs" => "", // Rust + "py" => "ξ˜†", // Python + "js" => "", // JavaScript + "ts" => "", // TypeScript + "jsx" | "tsx" => "", // React + "html" => "", // HTML + "css" => "ξ˜”", // CSS + "scss" | "sass" => "ξ˜ƒ", // SASS + "json" => "ξ˜‹", // JSON + "toml" => "ξ˜•", // TOML + "yaml" | "yml" => "ξ˜•", // YAML + "xml" => "", // XML + "c" => "", // C + "cpp" | "cxx" | "cc" => "", // C++ + "h" | "hpp" => "", // Header files + "go" => "", // Go + "java" => "", // Java + "kt" | "kts" => "", // Kotlin + "rb" => "ξž‘", // Ruby + "php" => "", // PHP + "cs" => "", // C# + "swift" => "", // Swift + "dart" => "", // Dart + "lua" => "", // Lua + "sh" | "bash" | "zsh" | "fish" => "ξž•", // Shell scripts + + "md" | "markdown" => "", // Markdown + "txt" => "ξ­©", // Text + "pdf" => "ξ««", // PDF + "doc" | "docx" => "", // Word + "xls" | "xlsx" => "", // Excel + "ppt" | "pptx" => "󰈧", // PowerPoint + + "png" | "jpg" | "jpeg" | "gif" | "bmp" => "", // Images + "svg" => "", // SVG + "mp3" | "wav" | "ogg" | "flac" => "", // Audio + "mp4" | "mkv" | "avi" | "mov" | "webm" => "", // Video + + "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" => "", // Archives + + "exe" | "msi" => "", // Windows executable + "app" | "dmg" => "", // macOS application + "db" | "sqlite" | "sqlite3" => "ξ˜…", // Databases + "csv" => "ξ»Ό", // CSV + "ini" | "conf" | "config" => "ξ˜•", // Config files + _ => "ο…›", + }, + } +} diff --git a/src/ui/find_window.rs b/src/ui/find_window.rs index e68f704..cf7fa55 100644 --- a/src/ui/find_window.rs +++ b/src/ui/find_window.rs @@ -1,5 +1,6 @@ use crate::app::TextEditor; use crate::ui::constants::*; +use crate::ui::focus_manager::{FocusTarget, priorities}; use eframe::egui; pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) { @@ -67,13 +68,13 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) { } if just_opened || focus_requested || app.focus_find { - response.request_focus(); + app.focus_manager.request_focus(FocusTarget::FindInput, priorities::HIGH); app.focus_find = false; } if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { app.find_next(ctx); - response.request_focus(); + app.focus_manager.request_focus(FocusTarget::FindInput, priorities::HIGH); } }); diff --git a/src/ui/focus_manager.rs b/src/ui/focus_manager.rs new file mode 100644 index 0000000..c49b683 --- /dev/null +++ b/src/ui/focus_manager.rs @@ -0,0 +1,110 @@ +use eframe::egui; + +/// Represents the different focusable components in the application +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusTarget { + /// Main text editor + Editor, + /// Find window input field + FindInput, + /// File tree rename input + FileTreeRename, + /// Font size input in preferences + FontSizeInput, + /// Tab width input in preferences + TabWidthInput, +} + +/// Centralized focus management system to prevent focus conflicts between components +pub struct FocusManager { + /// The currently requested focus target + current_target: Option, + /// Priority of the current focus request (higher = more important) + current_priority: i32, + /// Whether focus should be forced (ignore other requests) + force_focus: bool, +} + +impl Default for FocusManager { + fn default() -> Self { + Self::new() + } +} + +impl FocusManager { + pub fn new() -> Self { + Self { + current_target: None, + current_priority: 0, + force_focus: false, + } + } + + /// Request focus for a specific target with a given priority + /// Higher priority requests will override lower priority ones + pub fn request_focus(&mut self, target: FocusTarget, priority: i32) { + if priority >= self.current_priority || self.force_focus { + self.current_target = Some(target); + self.current_priority = priority; + self.force_focus = false; + } + } + + /// Force focus to a target, ignoring priority (use sparingly) + pub fn force_focus(&mut self, target: FocusTarget) { + self.current_target = Some(target); + self.force_focus = true; + } + + /// Clear the current focus request + pub fn clear_focus(&mut self) { + self.current_target = None; + self.current_priority = 0; + self.force_focus = false; + } + + /// Get the current focus target + pub fn get_current_target(&self) -> Option { + self.current_target + } + + /// Check if a specific target currently has focus + pub fn has_focus(&self, target: FocusTarget) -> bool { + self.current_target == Some(target) + } + + /// Apply the current focus request to the UI context + pub fn apply_focus(&mut self, ctx: &egui::Context) { + if let Some(target) = self.current_target { + let id = match target { + FocusTarget::Editor => egui::Id::new("main_text_editor"), + FocusTarget::FindInput => egui::Id::new("find_input"), + FocusTarget::FileTreeRename => egui::Id::new("file_tree_rename"), + FocusTarget::FontSizeInput => egui::Id::new("font_size_input"), + FocusTarget::TabWidthInput => egui::Id::new("tab_width_input"), + }; + + ctx.memory_mut(|mem| { + mem.request_focus(id); + }); + + // Clear the request after applying it + self.clear_focus(); + } + } + + /// Reset focus state at the beginning of each frame + pub fn reset(&mut self) { + self.current_target = None; + self.current_priority = 0; + self.force_focus = false; + } +} + +/// Priority levels for focus requests (higher = more important) +pub mod priorities { + pub const LOW: i32 = 10; + pub const NORMAL: i32 = 50; + pub const HIGH: i32 = 100; + pub const CRITICAL: i32 = 200; +} \ No newline at end of file diff --git a/src/ui/menu_bar.rs b/src/ui/menu_bar.rs index 3e087a1..097aaeb 100644 --- a/src/ui/menu_bar.rs +++ b/src/ui/menu_bar.rs @@ -261,13 +261,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, "Show Line Numbers") - .clicked() - { - app.save_config(); - ui.close_kind(UiKind::Menu); - } + if ui .checkbox(&mut app.syntax_highlighting, "Syntax Highlighting") .clicked() @@ -286,34 +280,62 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { app.save_config(); ui.close_kind(UiKind::Menu); } - if ui.checkbox(&mut app.hide_tab_bar, "Hide Tab Bar").clicked() { + if ui.checkbox(&mut app.show_hidden_files, "Show Hidden Files").clicked() { app.save_config(); ui.close_kind(UiKind::Menu); } - if ui - .checkbox(&mut app.hide_bottom_bar, "Hide Bottom Bar") - .clicked() - { - app.save_config(); - ui.close_kind(UiKind::Menu); - } - if ui - .checkbox(&mut app.auto_hide_toolbar, "Auto Hide Toolbar") - .clicked() - { - app.save_config(); - ui.close_kind(UiKind::Menu); - } - - ui.separator(); - - if ui.button("Reset Zoom").clicked() { - app.zoom_factor = 1.0; - ctx.set_zoom_factor(1.0); - ui.close_kind(UiKind::Menu); - } - - ui.separator(); + ui.menu_button("Layout", |ui| { + ui.menu_button("Line Numbers", |ui| { + if ui.checkbox(&mut app.show_line_numbers, "Show").clicked() { + app.save_config(); + ui.close_kind(UiKind::Menu); + } + if ui.radio_value(&mut app.line_side, false, "Left").clicked() { + app.save_config(); + ui.close_kind(UiKind::Menu); + } + if ui.radio_value(&mut app.line_side, true, "Right").clicked() { + app.save_config(); + ui.close_kind(UiKind::Menu); + } + }); + ui.menu_button("File Tree", |ui| { + if ui.checkbox(&mut app.show_file_tree, "Show").clicked() { + app.save_config(); + ui.close_kind(UiKind::Menu); + } + if ui.radio_value(&mut app.file_tree_side, false, "Left").clicked() { + app.save_config(); + ui.close_kind(UiKind::Menu); + } + if ui.radio_value(&mut app.file_tree_side, true, "Right").clicked() { + app.save_config(); + ui.close_kind(UiKind::Menu); + } + }); + if ui.checkbox(&mut app.show_tab_bar, "Tab Bar").clicked() { + app.save_config(); + ui.close_kind(UiKind::Menu); + } + if ui + .checkbox(&mut app.show_bottom_bar, "Bottom Bar") + .clicked() + { + app.save_config(); + ui.close_kind(UiKind::Menu); + } + if ui.checkbox(&mut app.show_terminal, "Terminal").clicked() { + app.save_config(); + ui.close_kind(UiKind::Menu); + } + if ui + .checkbox(&mut app.auto_hide_toolbar, "Hide Toolbar") + .clicked() + { + app.save_config(); + ui.close_kind(UiKind::Menu); + } + }); ui.menu_button("Appearance", |ui| { app.menu_interaction_active = true; @@ -355,14 +377,18 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { ui.close_kind(UiKind::Menu); } ui.separator(); - if ui.radio_value(&mut app.line_side, false, "Left").clicked() { - app.save_config(); - ui.close_kind(UiKind::Menu); - } - if ui.radio_value(&mut app.line_side, true, "Right").clicked() { + + if ui.button("Reset Zoom").clicked() { + app.zoom_factor = 1.0; + ctx.set_zoom_factor(1.0); + ui.close_kind(UiKind::Menu); + } + if ui.button("Focus Mode").clicked() { + app.focus_mode = true; app.save_config(); ui.close_kind(UiKind::Menu); } + }); }); @@ -378,7 +404,7 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { } }); - if app.hide_tab_bar { + if !app.show_tab_bar { let tab_title = if let Some(tab) = app.get_active_tab() { tab.get_display_title() } else { diff --git a/src/ui/preferences_window.rs b/src/ui/preferences_window.rs index 0bb9ac8..3ad5500 100644 --- a/src/ui/preferences_window.rs +++ b/src/ui/preferences_window.rs @@ -1,5 +1,6 @@ use crate::app::TextEditor; use crate::ui::constants::*; +use crate::ui::focus_manager::{FocusTarget, priorities}; use eframe::egui; pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { @@ -30,7 +31,7 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { .show(ctx, |ui| { ui.vertical_centered(|ui| { ui.horizontal(|ui| { - ui.vertical(|ui| { + ui.vertical(|ui|{ if ui .checkbox(&mut app.state_cache, "Maintain State") .on_hover_text("Unsaved changes will be cached between sessions") @@ -44,28 +45,19 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { } } ui.add_space(SMALL); - if ui - .checkbox(&mut app.show_line_numbers, "Show Line Numbers") - .changed() - { - app.save_config(); - } - ui.add_space(SMALL); - if ui - .checkbox(&mut app.auto_hide_toolbar, "Auto Hide Toolbar") - .on_hover_text( - "Hide the top bar until you move your mouse to the upper edge", - ) - .changed() - { - app.save_config(); - } - }); - ui.vertical(|ui| { if ui.checkbox(&mut app.word_wrap, "Word Wrap").changed() { app.save_config(); } ui.add_space(SMALL); + if ui.checkbox(&mut app.show_hidden_files, "Show Hidden Files").on_hover_text("Show files and directories starting with '.'").changed() { + app.save_config(); + } + ui.add_space(SMALL); + if ui.checkbox(&mut app.follow_git, "Git").on_hover_text("Respect .gitignore file").changed() { + app.save_config(); + } + }); + ui.vertical(|ui|{ if ui .checkbox(&mut app.syntax_highlighting, "Syntax Highlighting") .changed() @@ -74,10 +66,54 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { } ui.add_space(SMALL); if ui - .checkbox(&mut app.hide_tab_bar, "Hide Tab Bar") - .on_hover_text( - "Hide the tab bar and show tab title in menu bar instead", - ) + .checkbox(&mut app.auto_indent, "Auto Indent") + .on_hover_text("Automatically indent new lines to match the previous line") + .changed() + { + app.save_config(); + } + ui.add_space(SMALL); + if ui.checkbox(&mut app.show_markdown, "Preview Markdown").changed() { + app.save_config(); + } + }); + }); + + ui.add_space(MEDIUM); + ui.separator(); + ui.add_space(SMALL); + + ui.horizontal(|ui| { + ui.vertical(|ui| { + if ui.checkbox(&mut app.show_line_numbers, "Line Numbers").changed() { + app.save_config(); + } + ui.add_space(SMALL); + ui.radio_value(&mut app.line_side, false, "Left"); + ui.add_space(SMALL); + ui.radio_value(&mut app.line_side, true, "Right"); + }); + ui.vertical(|ui| { + if ui.checkbox(&mut app.show_file_tree, "File Tree").changed() { + app.save_config(); + } + ui.add_space(SMALL); + ui.radio_value(&mut app.file_tree_side, false, "Left"); + ui.add_space(SMALL); + ui.radio_value(&mut app.file_tree_side, true, "Right"); + }); + ui.vertical(|ui| { + if ui.checkbox(&mut app.show_tab_bar, "Tab Bar").changed() { + app.save_config(); + } + ui.add_space(SMALL); + if ui.checkbox(&mut app.show_bottom_bar, "Bottom Bar").changed() { + app.save_config(); + } + ui.add_space(SMALL); + if ui + .checkbox(&mut app.auto_hide_toolbar, "Hide Toolbar") + .on_hover_text("Hide the toolbar until cursor moves to top of the window") .changed() { app.save_config(); @@ -85,7 +121,7 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { }); }); - ui.add_space(SMALL); + ui.add_space(MEDIUM); ui.separator(); ui.add_space(SMALL); @@ -144,7 +180,7 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { app.font_size_input = Some(font_size_text.to_owned()); if response.clicked() { - response.request_focus(); + app.focus_manager.request_focus(FocusTarget::FontSizeInput, priorities::NORMAL); } ui.label("pt"); @@ -165,6 +201,50 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { } }) }); + ui.vertical(|ui| { + ui.label("Tab Char:").on_hover_text("Use tab character instead of spaces for indentation"); + ui.add_space(MEDIUM); + ui.label("Tab Width:").on_hover_text("Preferred number of spaces for indentation"); + }); + + ui.vertical(|ui| { + if ui.checkbox(&mut app.tab_char, "").changed() { + app.save_config(); + } + ui.add_space(SMALL * 1.5); + if app.tab_width_input.is_none() { + app.tab_width_input = Some(app.tab_width.to_string()); + } + let mut tab_width_text = app + .tab_width_input + .as_ref() + .unwrap_or(&"4".to_string()) + .to_owned(); + ui.horizontal(|ui| { + let response = ui.add( + egui::TextEdit::singleline(&mut tab_width_text) + .desired_width(FONT_SIZE_INPUT_WIDTH) + .hint_text("4").id(egui::Id::new("tab_width_input")), + ); + + app.tab_width_input = Some(tab_width_text.to_owned()); + + if response.clicked() { + app.focus_manager.request_focus(FocusTarget::TabWidthInput, priorities::NORMAL); + } + + if response.lost_focus() { + if let Ok(new_width) = tab_width_text.parse::() { + let clamped_width = new_width.clamp(1, 8); + if app.tab_width != clamped_width { + app.tab_width = clamped_width; + app.apply_font_settings(ctx); + } + } + app.tab_width_input = None; + } + }); + }); }); ui.add_space(MEDIUM); diff --git a/src/ui/shell_bar.rs b/src/ui/shell_bar.rs new file mode 100644 index 0000000..120e823 --- /dev/null +++ b/src/ui/shell_bar.rs @@ -0,0 +1,288 @@ +use crate::app::TextEditor; +use crate::ui::constants::*; +use eframe::egui::{self, Frame, Id, ScrollArea, TextEdit}; +use nix::pty::{Winsize, openpty}; +use nix::unistd::{ForkResult, close, dup2, execvp, fork, setsid}; +use std::ffi::CString; +use std::os::fd::{AsRawFd, OwnedFd}; +use std::sync::{Arc, Mutex}; + +pub struct ShellState { + pub pty: Option, + pub output_buffer: Arc>, + pub input_buffer: String, + pub scroll_to_bottom: bool, + pub input_id: Id, +} + +pub struct PtyHandle { + pub master: OwnedFd, + pub _reader_thread: std::thread::JoinHandle<()>, +} + +impl Default for ShellState { + fn default() -> Self { + Self { + pty: None, + output_buffer: Arc::new(Mutex::new(String::new())), + input_buffer: String::new(), + scroll_to_bottom: false, + input_id: Id::new("terminal_input"), + } + } +} + +impl ShellState { + pub fn start_shell(&mut self) { + if self.pty.is_some() { + return; + } + + // Get the user's shell + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); + + if let Ok(mut output) = self.output_buffer.lock() { + output.push_str(&format!("Starting shell: {}\n", shell)); + } + + // Open PTY + let pty_result = openpty( + Some(&Winsize { + ws_row: 24, + ws_col: 80, + ws_xpixel: 0, + ws_ypixel: 0, + }), + None, + ); + + let pty = match pty_result { + Ok(pty) => { + if let Ok(mut output) = self.output_buffer.lock() { + output.push_str("PTY created successfully\n"); + } + pty + } + Err(e) => { + if let Ok(mut output) = self.output_buffer.lock() { + output.push_str(&format!("Failed to create PTY: {}\n", e)); + } + return; + } + }; + + // Fork to create child process + match unsafe { fork() } { + Ok(ForkResult::Parent { child }) => { + // Parent process + if let Ok(mut output) = self.output_buffer.lock() { + output.push_str(&format!("Forked child process: {:?}\n", child)); + } + + // Close the slave side + let _ = close(pty.slave.as_raw_fd()); + + let master_fd = pty.master.as_raw_fd(); + + if let Ok(mut output) = self.output_buffer.lock() { + output.push_str(&format!("Master fd: {}\n", master_fd)); + } + + // Set master to non-blocking mode + use nix::fcntl::{FcntlArg, OFlag, fcntl}; + let _ = fcntl(master_fd, FcntlArg::F_SETFL(OFlag::O_NONBLOCK)); + + let output_buffer = Arc::clone(&self.output_buffer); + + // Spawn reader thread that polls the master fd + let reader_thread = std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + + loop { + // Use nix::unistd::read to read from the fd + match nix::unistd::read(master_fd, &mut buf) { + Ok(0) => { + if let Ok(mut output) = output_buffer.lock() { + output.push_str("\n[EOF from shell]\n"); + } + break; + } + Ok(n) => { + let text = String::from_utf8_lossy(&buf[..n]).to_string(); + if let Ok(mut output) = output_buffer.lock() { + output.push_str(&text); + if output.len() > 100_000 { + output.drain(..50_000); + } + } + } + Err(nix::errno::Errno::EAGAIN) + | Err(nix::errno::Errno::EWOULDBLOCK) => { + // No data available, sleep briefly + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(e) => { + if let Ok(mut output) = output_buffer.lock() { + output.push_str(&format!("\n[Read error: {:?}]\n", e)); + } + break; + } + } + } + }); + + self.pty = Some(PtyHandle { + master: pty.master, + _reader_thread: reader_thread, + }); + + self.scroll_to_bottom = true; + } + Ok(ForkResult::Child) => { + // Child process + // Close the master side + let _ = close(pty.master.as_raw_fd()); + + // Create a new session + let _ = setsid(); + + let slave_fd = pty.slave.as_raw_fd(); + + // Make this PTY the controlling terminal + unsafe { + libc::ioctl(slave_fd, libc::TIOCSCTTY, 0); + } + + // Redirect stdin, stdout, stderr to the slave PTY + let _ = dup2(slave_fd, 0); // stdin + let _ = dup2(slave_fd, 1); // stdout + let _ = dup2(slave_fd, 2); // stderr + + // Close the slave fd since it's been duplicated + if slave_fd > 2 { + let _ = close(slave_fd); + } + + // Set TERM environment variable + unsafe { + std::env::set_var("TERM", "xterm-256color"); + } + + // Execute the shell with -i for interactive mode + let shell_cstr = CString::new(shell.as_bytes()).unwrap(); + let arg_i = CString::new("-i").unwrap(); + let args = [shell_cstr.clone(), arg_i]; + let _ = execvp(&shell_cstr, &args); + + // If execvp returns, it failed + eprintln!("Failed to execute shell"); + std::process::exit(1); + } + Err(e) => { + if let Ok(mut output) = self.output_buffer.lock() { + output.push_str(&format!("Fork failed: {}\n", e)); + } + } + } + } + + pub fn send_input(&mut self, text: &str) { + if let Some(ref pty) = self.pty { + let input_bytes = format!("{}\n", text).into_bytes(); + + // Use nix::unistd::write to write to the fd + use std::os::fd::AsFd; + let _ = nix::unistd::write(pty.master.as_fd(), &input_bytes); + + self.scroll_to_bottom = true; + } + } + + pub fn clear_output(&mut self) { + if let Ok(mut output) = self.output_buffer.lock() { + output.clear(); + } + } +} + +pub(crate) fn shell_bar(app: &mut TextEditor, ctx: &egui::Context) { + // Auto-start shell on first show + if app.show_terminal && app.shell_state.pty.is_none() { + app.shell_state.start_shell(); + } + + let frame = Frame::NONE.fill(ctx.style().visuals.panel_fill); + + egui::TopBottomPanel::bottom("shell_bar") + .frame(frame) + .min_height(200.0) + .default_height(300.0) + .resizable(true) + .show_animated(ctx, app.show_terminal, |ui| { + ui.vertical(|ui| { + ui.add_space(SMALL); + // Simplified header + ui.horizontal(|ui| { + ui.add_space(SMALL); + if ui.button("Clear").clicked() { + app.shell_state.clear_output(); + } + }); + + ui.separator(); + + // Output area + let output_height = ui.available_height() - 35.0; + + ScrollArea::vertical() + .auto_shrink([false, false]) + .stick_to_bottom(app.shell_state.scroll_to_bottom) + .max_height(output_height) + .show(ui, |ui| { + ui.style_mut().override_font_id = Some(egui::FontId::monospace(12.0)); + + let output_text = if let Ok(output) = app.shell_state.output_buffer.lock() { + if output.is_empty() { + "".to_string() + } else { + output.clone() + } + } else { + String::new() + }; + + ui.label(output_text); + }); + + if app.shell_state.scroll_to_bottom { + app.shell_state.scroll_to_bottom = false; + } + + ui.separator(); + + // Input line + ui.horizontal(|ui| { + ui.style_mut().override_font_id = Some(egui::FontId::monospace(12.0)); + + let response = ui.add( + TextEdit::singleline(&mut app.shell_state.input_buffer) + .id(app.shell_state.input_id) + .desired_width(f32::INFINITY), + ); + + if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + let input = app.shell_state.input_buffer.clone(); + app.shell_state.input_buffer.clear(); + app.shell_state.send_input(&input); + // Re-focus after command + ui.memory_mut(|mem| mem.request_focus(app.shell_state.input_id)); + } + }); + }); + }); + + // Request repaint when shell is active + if app.show_terminal && app.shell_state.pty.is_some() { + ctx.request_repaint(); + } +} diff --git a/src/ui/shortcuts_window.rs b/src/ui/shortcuts_window.rs index eda6ce4..45c5a16 100644 --- a/src/ui/shortcuts_window.rs +++ b/src/ui/shortcuts_window.rs @@ -3,58 +3,157 @@ use crate::ui::constants::*; use eframe::egui; fn render_shortcuts_content(ui: &mut egui::Ui) { - ui.vertical_centered(|ui| { - ui.label( - egui::RichText::new("Navigation") - .size(UI_HEADER_SIZE) - .strong(), - ); - ui.label(egui::RichText::new("Ctrl + N: New").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + O: Open").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + S: Save").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + Shift + S: Save As").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + T: New Tab").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + W: Close Tab").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + Tab: Next Tab").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + Shift + Tab: Last Tab").size(UI_TEXT_SIZE)); - ui.add_space(VLARGE); - ui.separator(); + let description_color = ui.visuals().text_color().gamma_multiply(0.8); - ui.label(egui::RichText::new("Editing").size(UI_HEADER_SIZE).strong()); - ui.label(egui::RichText::new("Ctrl + Z: Undo").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + Shift + Z: Redo").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + X: Cut").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + C: Copy").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + V: Paste").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + A: Select All").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + D: Delete Line").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + F: Find").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + R: Replace").size(UI_TEXT_SIZE)); + ui.vertical(|ui| { + ui.add_space(MEDIUM); + + // Navigation section + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new("Navigation") + .size(UI_HEADER_SIZE) + .strong(), + ); + }); + ui.add_space(MEDIUM); + + ui.horizontal(|ui| { + ui.add_space(MEDIUM); + ui.columns(2, |columns| { + let shortcuts = [ + ("Ctrl + N", "New"), + ("Ctrl + O", "Open"), + ("Ctrl + S", "Save"), + ("Ctrl + Shift + S", "Save As"), + ("Ctrl + T", "New Tab"), + ("Ctrl + W", "Close Tab"), + ("Ctrl + Tab", "Next Tab"), + ("Ctrl + Shift + Tab", "Last Tab"), + ]; + + for (i, (shortcut, description)) in shortcuts.iter().enumerate() { + let col = i % 2; + columns[col].label( + egui::RichText::new(*shortcut) + .size(UI_TEXT_SIZE) + .strong() + .monospace(), + ); + columns[col].label( + egui::RichText::new(*description) + .size(UI_TEXT_SIZE) + .color(description_color), + ); + + // Add space after each complete row (every 2 items) + if i % 2 == 1 { + columns[0].add_space(MEDIUM); + columns[1].add_space(MEDIUM); + } + } + }); + }); ui.add_space(VLARGE); ui.separator(); - ui.label(egui::RichText::new("Views").size(UI_HEADER_SIZE).strong()); - ui.label(egui::RichText::new("Ctrl + L: Toggle Line Numbers").size(UI_TEXT_SIZE)); - ui.label( - egui::RichText::new("Ctrl + Shift + L: Change Line Number Side").size(UI_TEXT_SIZE), - ); - ui.label(egui::RichText::new("Ctrl + K: Toggle Word Wrap").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + H: Toggle Auto Hide Toolbar").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + B: Toggle Bottom Bar").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + M: Toggle Markdown Preview").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + P: Preferences").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + =/-: Increase/Decrease Font Size").size(UI_TEXT_SIZE)); - ui.label(egui::RichText::new("Ctrl + Shift + =/-: Zoom In/Out").size(UI_TEXT_SIZE)); - // ui.label( - // egui::RichText::new("Ctrl + Shift + .: Toggle Vim Mode") - // .size(14.0) - // ); - // ui.label( - // egui::RichText::new("Ctrl + .: Toggle Vim Mode") - // .size(14.0) - // ); + ui.add_space(MEDIUM); + + // Editing section + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new("Editing").size(UI_HEADER_SIZE).strong()); + }); + ui.add_space(MEDIUM); + + ui.horizontal(|ui| { + ui.add_space(MEDIUM); + ui.columns(2, |columns| { + let shortcuts = [ + ("Ctrl + Z", "Undo"), + ("Ctrl + Shift + Z", "Redo"), + ("Ctrl + X", "Cut"), + ("Ctrl + C", "Copy"), + ("Ctrl + V", "Paste"), + ("Ctrl + A", "Select All"), + ("Ctrl + F", "Find"), + ("Ctrl + R", "Replace"), + ]; + + for (i, (shortcut, description)) in shortcuts.iter().enumerate() { + let col = i % 2; + columns[col].label( + egui::RichText::new(*shortcut) + .size(UI_TEXT_SIZE) + .strong() + .monospace(), + ); + columns[col].label( + egui::RichText::new(*description) + .size(UI_TEXT_SIZE) + .color(description_color), + ); + + // Add space after each complete row (every 2 items) + if i % 2 == 1 { + columns[0].add_space(MEDIUM); + columns[1].add_space(MEDIUM); + } + } + }); + }); + + ui.add_space(VLARGE); + ui.separator(); + ui.add_space(MEDIUM); + + // Views section + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new("Views").size(UI_HEADER_SIZE).strong()); + }); + ui.add_space(MEDIUM); + + ui.horizontal(|ui| { + ui.add_space(MEDIUM); + ui.columns(2, |columns| { + let shortcuts = [ + ("Ctrl + H", "Toggle Auto Hide Toolbar"), + ("Ctrl + B", "Toggle Bottom Bar"), + ("Ctrl + K", "Toggle Word Wrap"), + ("Ctrl + M", "Toggle Markdown Preview"), + ("Ctrl + L", "Toggle Line Numbers"), + ("Ctrl + Shift + L", "Change Line Number Side"), + ("Ctrl + E", "Toggle File Tree"), + ("Ctrl + Shift + E", "Toggle File Tree Side"), + ("Ctrl + =/-", "Increase/Decrease Font Size"), + ("Ctrl + Shift + =/-", "Zoom In/Out"), + ("Ctrl + P", "Preferences"), + ("Ctrl + Alt + F", "Toggle Focus Mode"), + ]; + + for (i, (shortcut, description)) in shortcuts.iter().enumerate() { + let col = i % 2; + columns[col].label( + egui::RichText::new(*shortcut) + .size(UI_TEXT_SIZE) + .strong() + .monospace(), + ); + columns[col].label( + egui::RichText::new(*description) + .size(UI_TEXT_SIZE) + .color(description_color), + ); + + // Add space after each complete row (every 2 items) + if i % 2 == 1 { + columns[0].add_space(MEDIUM); + columns[1].add_space(MEDIUM); + } + } + }); + }); + ui.add_space(VLARGE); - //ui.separator(); }); }