Compare commits

..

No commits in common. "1dbfd4e233a88a469a1749f663fb4e603830bc95" and "a3158129d1e353408d54af39ffb7c2d9694951ff" have entirely different histories.

32 changed files with 880 additions and 3198 deletions

View File

@ -1,12 +1,12 @@
[package] [package]
name = "ced" name = "ced"
version = "0.3.3" version = "0.1.3"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
eframe = "0.33.3" eframe = "0.32"
egui = "0.33.3" egui = "0.32"
egui_extras = { version = "0.33.3", features = ["syntect"] } egui_extras = { version = "0.32", features = ["syntect"] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.141" serde_json = "1.0.141"
rfd = "0.15.4" rfd = "0.15.4"
@ -17,7 +17,3 @@ syntect = "5.2.0"
plist = "1.7.4" plist = "1.7.4"
diffy = "0.4.2" diffy = "0.4.2"
uuid = { version = "1.0", features = ["v4"] } uuid = { version = "1.0", features = ["v4"] }
egui_commonmark = { version = "0.22" }
egui_nerdfonts = "0.1.3"
vte = "0.13"
nix = { version = "0.29", features = ["term", "process", "fs"] }

View File

@ -10,8 +10,6 @@ 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.). * 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. * 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` + `+`/`-`). * Separate UI zoom that doesn't affect font size (`Ctrl+Shift` + `+`/`-`).
* Ricers rejoice, your `pywal` colors will be used! * Ricers rejoice, your `pywal` colors will be used!
* Weirdly smooth typing experience. * Weirdly smooth typing experience.
@ -24,7 +22,7 @@ There is a disturbing lack of simple GUI text editors available on Linux nativel
##### Ubuntu/Debian ##### Ubuntu/Debian
`sudo apt install git rust` `sudo apt install git rust`
### Install #### Install
```bash ```bash
git clone https://code.lampnet.io/candle/ced git clone https://code.lampnet.io/candle/ced
cd ced && cargo build --release cd ced && cargo build --release
@ -34,7 +32,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. `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`. `ced` will look for, and create if needed, a configuration file at: `$XDG_CONFIG_HOME/ced/config.toml`.
@ -47,8 +45,6 @@ show_line_numbers = false
word_wrap = false word_wrap = false
theme = "System" theme = "System"
line_side = false line_side = false
show_file_tree = true
file_tree_side = false
font_family = "Monospace" font_family = "Monospace"
font_size = 16.0 font_size = 16.0
syntax_highlighting = true syntax_highlighting = true
@ -60,12 +56,10 @@ syntax_highlighting = true
|--------|---------|-------------| |--------|---------|-------------|
| `state_cache` | `false` | If `true`, opened files will remain opened with their unsaved changes when running the application again. | | `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. | | `auto_hide_toolbar` | `false` | If `true`, the menu bar at the top will be hidden. Move your mouse to the top of the window to reveal it. |
| `show_tab_bar` | 'true' | If `false`, a separate tab bar will be drawn below the toolbar. | | `hide_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_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. | | `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. | | `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. | | `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_family` | `"Proportional"` | The font family used for the editor text. |
| `font_size` | `14.0` | `8.0-32.0` The font size for text editing. | | `font_size` | `14.0` | `8.0-32.0` The font size for text editing. |

View File

@ -1,4 +1,3 @@
pub mod actions;
pub mod config; pub mod config;
pub mod shortcuts; pub mod shortcuts;
pub mod state; pub mod state;

View File

@ -1,33 +0,0 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShortcutAction {
NewFile,
OpenFile,
SaveFile,
SaveAsFile,
NewTab,
CloseTab,
ToggleLineNumbers,
ToggleLineSide,
ToggleWordWrap,
ToggleAutoHideToolbar,
ToggleBottomBar,
ToggleFileTree,
ToggleFileTreeSide,
ToggleFind,
ToggleReplace,
ToggleMarkdown,
FocusFind,
NextTab,
PrevTab,
PageUp,
PageDown,
ZoomIn,
ZoomOut,
GlobalZoomIn,
GlobalZoomOut,
ResetZoom,
Escape,
Preferences,
ToggleVimMode,
ToggleFocusMode,
}

View File

@ -4,54 +4,72 @@ use std::path::PathBuf;
use super::theme::Theme; use super::theme::Theme;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config { pub struct Config {
#[serde(default = "default_state_cache")]
pub state_cache: bool, pub state_cache: bool,
#[serde(default = "default_auto_hide_toolbar")]
pub auto_hide_toolbar: bool, pub auto_hide_toolbar: bool,
pub show_tab_bar: bool, #[serde(default = "default_hide_tab_bar")]
pub show_bottom_bar: bool, pub hide_tab_bar: bool,
pub show_file_tree: bool, #[serde(default = "default_show_line_numbers")]
pub show_line_numbers: bool, pub show_line_numbers: bool,
#[serde(default = "default_word_wrap")]
pub word_wrap: bool, pub word_wrap: bool,
#[serde(default = "Theme::default")]
pub theme: Theme, pub theme: Theme,
#[serde(default = "default_line_side")]
pub line_side: bool, pub line_side: bool,
pub file_tree_side: bool, #[serde(default = "default_font_family")]
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_family: String,
#[serde(default = "default_font_size")]
pub font_size: f32, pub font_size: f32,
#[serde(default = "default_syntax_highlighting")]
pub syntax_highlighting: bool, pub syntax_highlighting: bool,
pub auto_indent: bool,
pub focus_mode: bool,
// pub vim_mode: bool, // pub vim_mode: bool,
} }
fn default_state_cache() -> bool {
false
}
fn default_auto_hide_toolbar() -> bool {
false
}
fn default_hide_tab_bar() -> bool {
true
}
fn default_show_line_numbers() -> bool {
false
}
fn default_word_wrap() -> bool {
true
}
fn default_line_side() -> bool {
false
}
fn default_font_family() -> String {
"Proportional".to_string()
}
fn default_font_size() -> f32 {
14.0
}
fn default_syntax_highlighting() -> bool {
false
}
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Self { Self {
state_cache: false, state_cache: default_state_cache(),
auto_hide_toolbar: false, auto_hide_toolbar: default_auto_hide_toolbar(),
show_tab_bar: false, hide_tab_bar: default_hide_tab_bar(),
show_bottom_bar: true, show_line_numbers: default_show_line_numbers(),
show_file_tree: false, word_wrap: default_word_wrap(),
show_line_numbers: false,
word_wrap: true,
theme: Theme::default(), theme: Theme::default(),
line_side: false, line_side: default_line_side(),
file_tree_side: false, font_family: default_font_family(),
show_hidden_files: false, font_size: default_font_size(),
show_terminal: false, syntax_highlighting: default_syntax_highlighting(),
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, // vim_mode: false,
} }
} }

View File

@ -1,7 +1,36 @@
use crate::app::actions::ShortcutAction;
use crate::app::state::TextEditor; use crate::app::state::TextEditor;
use crate::io;
use eframe::egui; use eframe::egui;
#[derive(Debug, Clone, Copy)]
enum ShortcutAction {
NewFile,
OpenFile,
SaveFile,
SaveAsFile,
NewTab,
CloseTab,
ToggleLineNumbers,
ToggleLineSide,
ToggleWordWrap,
ToggleAutoHideToolbar,
ToggleFind,
ToggleReplace,
FocusFind,
NextTab,
PrevTab,
PageUp,
PageDown,
ZoomIn,
ZoomOut,
GlobalZoomIn,
GlobalZoomOut,
ResetZoom,
Escape,
Preferences,
ToggleVimMode,
}
type ShortcutDefinition = (egui::Modifiers, egui::Key, ShortcutAction); type ShortcutDefinition = (egui::Modifiers, egui::Key, ShortcutAction);
fn get_shortcuts() -> Vec<ShortcutDefinition> { fn get_shortcuts() -> Vec<ShortcutDefinition> {
@ -28,11 +57,6 @@ fn get_shortcuts() -> Vec<ShortcutDefinition> {
egui::Key::W, egui::Key::W,
ShortcutAction::CloseTab, ShortcutAction::CloseTab,
), ),
(
egui::Modifiers::CTRL | egui::Modifiers::ALT,
egui::Key::F,
ShortcutAction::ToggleFocusMode,
),
( (
egui::Modifiers::CTRL | egui::Modifiers::SHIFT, egui::Modifiers::CTRL | egui::Modifiers::SHIFT,
egui::Key::F, egui::Key::F,
@ -68,21 +92,6 @@ fn get_shortcuts() -> Vec<ShortcutDefinition> {
egui::Key::H, egui::Key::H,
ShortcutAction::ToggleAutoHideToolbar, ShortcutAction::ToggleAutoHideToolbar,
), ),
(
egui::Modifiers::CTRL,
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::Modifiers::CTRL | egui::Modifiers::SHIFT,
egui::Key::Tab, egui::Key::Tab,
@ -143,23 +152,153 @@ fn get_shortcuts() -> Vec<ShortcutDefinition> {
egui::Key::Escape, egui::Key::Escape,
ShortcutAction::Escape, ShortcutAction::Escape,
), ),
(
egui::Modifiers::CTRL,
egui::Key::M,
ShortcutAction::ToggleMarkdown,
),
] ]
} }
fn execute_action(action: ShortcutAction, editor: &mut TextEditor) -> bool { fn execute_action(action: ShortcutAction, editor: &mut TextEditor) -> bool {
editor.perform_action(action) match action {
ShortcutAction::NewFile => {
io::new_file(editor);
false
}
ShortcutAction::OpenFile => {
io::open_file(editor);
false
}
ShortcutAction::SaveFile => {
io::save_file(editor);
false
}
ShortcutAction::SaveAsFile => {
io::save_as_file(editor);
false
}
ShortcutAction::NewTab => {
editor.add_new_tab();
false
}
ShortcutAction::CloseTab => {
if editor.tabs.len() > 1 {
if let Some(current_tab) = editor.get_active_tab() {
if current_tab.is_modified {
editor.pending_unsaved_action = Some(
super::state::UnsavedAction::CloseTab(editor.active_tab_index),
);
} else {
editor.close_tab(editor.active_tab_index);
}
}
}
false
}
ShortcutAction::ToggleLineNumbers => {
editor.show_line_numbers = !editor.show_line_numbers;
editor.save_config();
false
}
ShortcutAction::ToggleLineSide => {
editor.line_side = !editor.line_side;
editor.save_config();
false
}
ShortcutAction::ToggleWordWrap => {
editor.word_wrap = !editor.word_wrap;
editor.save_config();
false
}
ShortcutAction::ToggleAutoHideToolbar => {
editor.auto_hide_toolbar = !editor.auto_hide_toolbar;
editor.save_config();
false
}
ShortcutAction::NextTab => {
let next_tab_index = editor.active_tab_index + 1;
if next_tab_index < editor.tabs.len() {
editor.switch_to_tab(next_tab_index);
} else {
editor.switch_to_tab(0);
}
false
}
ShortcutAction::PrevTab => {
if editor.active_tab_index == 0 {
editor.switch_to_tab(editor.tabs.len() - 1);
} else {
editor.switch_to_tab(editor.active_tab_index - 1);
}
false
}
ShortcutAction::PageUp => false,
ShortcutAction::PageDown => false,
ShortcutAction::ZoomIn => {
editor.font_size += 1.0;
true
}
ShortcutAction::ZoomOut => {
editor.font_size -= 1.0;
true
}
ShortcutAction::GlobalZoomIn => {
editor.zoom_factor += 0.1;
false
}
ShortcutAction::GlobalZoomOut => {
editor.zoom_factor -= 0.1;
if editor.zoom_factor < 0.1 {
editor.zoom_factor = 0.1;
}
false
}
ShortcutAction::ResetZoom => {
editor.zoom_factor = 1.0;
false
}
ShortcutAction::ToggleVimMode => {
// editor.vim_mode = !editor.vim_mode;
false
}
ShortcutAction::Escape => {
editor.show_about = false;
editor.show_shortcuts = false;
if editor.show_find {
editor.should_select_current_match = true;
}
editor.show_find = false;
editor.show_preferences = false;
editor.pending_unsaved_action = None;
false
}
ShortcutAction::ToggleFind => {
editor.show_find = !editor.show_find;
if editor.show_find && !editor.find_query.is_empty() {
editor.update_find_matches();
}
false
}
ShortcutAction::ToggleReplace => {
editor.show_find = !editor.show_find;
editor.show_replace_section = true;
if editor.show_find && !editor.find_query.is_empty() {
editor.update_find_matches();
}
false
}
ShortcutAction::FocusFind => {
if editor.show_find {
editor.focus_find = true;
}
false
}
ShortcutAction::Preferences => {
editor.show_preferences = !editor.show_preferences;
false
}
}
} }
pub fn handle(editor: &mut TextEditor, ctx: &egui::Context) { pub fn handle(editor: &mut TextEditor, ctx: &egui::Context) {
let mut font_zoom_occurred = false; let mut font_zoom_occurred = false;
let mut global_zoom_occurred = false; let mut global_zoom_occurred = false;
let mut page_up_pressed = false;
let mut page_down_pressed = false;
ctx.input_mut(|i| { ctx.input_mut(|i| {
for (modifiers, key, action) in get_shortcuts() { for (modifiers, key, action) in get_shortcuts() {
@ -174,12 +313,6 @@ pub fn handle(editor: &mut TextEditor, ctx: &egui::Context) {
execute_action(action, editor); execute_action(action, editor);
global_zoom_occurred = true; global_zoom_occurred = true;
} }
ShortcutAction::PageUp => {
page_up_pressed = true;
}
ShortcutAction::PageDown => {
page_down_pressed = true;
}
_ => { _ => {
execute_action(action, editor); execute_action(action, editor);
} }
@ -197,14 +330,6 @@ pub fn handle(editor: &mut TextEditor, ctx: &egui::Context) {
ctx.set_zoom_factor(editor.zoom_factor); ctx.set_zoom_factor(editor.zoom_factor);
} }
if page_up_pressed {
editor.handle_page_movement(ctx, false);
}
if page_down_pressed {
editor.handle_page_movement(ctx, true);
}
if editor.should_select_current_match { if editor.should_select_current_match {
editor.select_current_match(ctx); editor.select_current_match(ctx);
editor.should_select_current_match = false; editor.should_select_current_match = false;

View File

@ -1,21 +1,15 @@
use super::editor::TextEditor; use super::editor::TextEditor;
use crate::app::shortcuts; use crate::app::shortcuts;
use crate::ui::about_window::about_window; use crate::ui::about_window::about_window;
use crate::ui::bottom_bar::bottom_bar;
use crate::ui::central_panel::central_panel; use crate::ui::central_panel::central_panel;
use crate::ui::file_tree::file_tree;
use crate::ui::find_window::find_window; use crate::ui::find_window::find_window;
use crate::ui::menu_bar::menu_bar; use crate::ui::menu_bar::menu_bar;
use crate::ui::preferences_window::preferences_window; use crate::ui::preferences_window::preferences_window;
use crate::ui::shortcuts_window::shortcuts_window; use crate::ui::shortcuts_window::shortcuts_window;
use crate::ui::tab_bar::tab_bar; use crate::ui::tab_bar::tab_bar;
use crate::ui::shell_bar::shell_bar;
impl eframe::App for TextEditor { impl eframe::App for TextEditor {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { 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()) if ctx.input(|i| i.viewport().close_requested())
&& !self.force_quit_confirmed && !self.force_quit_confirmed
&& !self.clean_quit_requested && !self.clean_quit_requested
@ -28,35 +22,21 @@ impl eframe::App for TextEditor {
ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.get_title())); ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.get_title()));
if !self.focus_mode {
menu_bar(self, ctx); menu_bar(self, ctx);
}
if self.show_tab_bar && !self.focus_mode { if !self.hide_tab_bar {
tab_bar(self, ctx); tab_bar(self, ctx);
} }
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); central_panel(self, ctx);
if self.show_about && !self.focus_mode { if self.show_about {
about_window(self, ctx); about_window(self, ctx);
} }
if self.show_shortcuts && !self.focus_mode { if self.show_shortcuts {
shortcuts_window(self, ctx); shortcuts_window(self, ctx);
} }
if self.show_preferences && !self.focus_mode { if self.show_preferences {
preferences_window(self, ctx); preferences_window(self, ctx);
} }
if self.show_find { if self.show_find {
@ -66,9 +46,6 @@ impl eframe::App for TextEditor {
self.show_unsaved_changes_dialog(ctx); 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; self.prev_show_find = self.show_find;
} }
} }

View File

@ -11,22 +11,12 @@ impl TextEditor {
show_line_numbers: config.show_line_numbers, show_line_numbers: config.show_line_numbers,
word_wrap: config.word_wrap, word_wrap: config.word_wrap,
auto_hide_toolbar: config.auto_hide_toolbar, auto_hide_toolbar: config.auto_hide_toolbar,
show_tab_bar: config.show_tab_bar, hide_tab_bar: config.hide_tab_bar,
show_bottom_bar: config.show_bottom_bar,
show_file_tree: config.show_file_tree,
show_terminal: config.show_terminal,
theme: config.theme, theme: config.theme,
line_side: config.line_side, 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_family: config.font_family,
font_size: config.font_size, font_size: config.font_size,
syntax_highlighting: config.syntax_highlighting, syntax_highlighting: config.syntax_highlighting,
auto_indent: config.auto_indent,
focus_mode: config.focus_mode,
..Default::default() ..Default::default()
} }
} }
@ -90,8 +80,6 @@ impl TextEditor {
editor.apply_font_settings(&cc.egui_ctx); 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 editor
} }
@ -100,23 +88,13 @@ impl TextEditor {
state_cache: self.state_cache, state_cache: self.state_cache,
auto_hide_toolbar: self.auto_hide_toolbar, auto_hide_toolbar: self.auto_hide_toolbar,
show_line_numbers: self.show_line_numbers, show_line_numbers: self.show_line_numbers,
show_tab_bar: self.show_tab_bar, hide_tab_bar: self.hide_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, word_wrap: self.word_wrap,
theme: self.theme, theme: self.theme,
line_side: self.line_side, 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_family: self.font_family.to_string(),
font_size: self.font_size, font_size: self.font_size,
syntax_highlighting: self.syntax_highlighting, syntax_highlighting: self.syntax_highlighting,
auto_indent: self.auto_indent,
focus_mode: self.focus_mode,
// vim_mode: self.vim_mode, // vim_mode: self.vim_mode,
} }
} }

View File

@ -1,7 +1,6 @@
use super::editor::TextEditor; use super::editor::TextEditor;
use super::editor::TextProcessingResult; use super::editor::TextProcessingResult;
use crate::app::{tab::Tab, theme::Theme}; use crate::app::{tab::Tab, theme::Theme};
use egui_commonmark::CommonMarkCache;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
impl Default for TextEditor { impl Default for TextEditor {
@ -15,32 +14,19 @@ impl Default for TextEditor {
show_shortcuts: false, show_shortcuts: false,
show_find: false, show_find: false,
show_preferences: false, show_preferences: false,
show_markdown: false,
pending_unsaved_action: None, pending_unsaved_action: None,
force_quit_confirmed: false, force_quit_confirmed: false,
clean_quit_requested: false, clean_quit_requested: false,
show_line_numbers: false, show_line_numbers: false,
word_wrap: true, word_wrap: true,
auto_hide_toolbar: false, auto_hide_toolbar: false,
show_tab_bar: false, hide_tab_bar: true,
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, syntax_highlighting: false,
auto_indent: true,
theme: Theme::default(), theme: Theme::default(),
line_side: false, 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_family: "Proportional".to_string(),
font_size: 14.0, font_size: 14.0,
font_size_input: None, font_size_input: None,
tab_width_input: None,
zoom_factor: 1.0, zoom_factor: 1.0,
menu_interaction_active: false, menu_interaction_active: false,
tab_bar_rect: None, tab_bar_rect: None,
@ -59,15 +45,10 @@ impl Default for TextEditor {
previous_content: String::new(), previous_content: String::new(),
previous_cursor_char_index: None, previous_cursor_char_index: None,
current_cursor_line: 0, current_cursor_line: 0,
current_cursor_index: 0,
previous_cursor_line: 0, previous_cursor_line: 0,
font_settings_changed: false, font_settings_changed: false,
text_needs_processing: false, text_needs_processing: false,
should_select_current_match: 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(),
} }
} }
} }

View File

@ -1,13 +1,6 @@
use crate::app::actions::ShortcutAction;
use crate::app::tab::Tab; use crate::app::tab::Tab;
use crate::app::theme::Theme; 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 eframe::egui;
use egui_commonmark::CommonMarkCache;
use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
@ -46,32 +39,19 @@ pub struct TextEditor {
pub(crate) show_shortcuts: bool, pub(crate) show_shortcuts: bool,
pub(crate) show_find: bool, pub(crate) show_find: bool,
pub(crate) show_preferences: bool, pub(crate) show_preferences: bool,
pub(crate) show_markdown: bool,
pub(crate) pending_unsaved_action: Option<UnsavedAction>, pub(crate) pending_unsaved_action: Option<UnsavedAction>,
pub(crate) force_quit_confirmed: bool, pub(crate) force_quit_confirmed: bool,
pub(crate) clean_quit_requested: bool, pub(crate) clean_quit_requested: bool,
pub(crate) show_line_numbers: bool, pub(crate) show_line_numbers: bool,
pub(crate) word_wrap: bool, pub(crate) word_wrap: bool,
pub(crate) auto_hide_toolbar: bool, pub(crate) auto_hide_toolbar: bool,
pub(crate) show_tab_bar: bool, pub(crate) hide_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<PathBuf>,
pub(crate) file_tree_state: FileTreeState,
pub(crate) syntax_highlighting: bool, pub(crate) syntax_highlighting: bool,
pub(crate) auto_indent: bool,
pub(crate) theme: Theme, pub(crate) theme: Theme,
pub(crate) line_side: bool, 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_family: String,
pub(crate) font_size: f32, pub(crate) font_size: f32,
pub(crate) font_size_input: Option<String>, pub(crate) font_size_input: Option<String>,
pub(crate) tab_width_input: Option<String>,
pub(crate) zoom_factor: f32, pub(crate) zoom_factor: f32,
pub(crate) menu_interaction_active: bool, pub(crate) menu_interaction_active: bool,
pub(crate) tab_bar_rect: Option<egui::Rect>, pub(crate) tab_bar_rect: Option<egui::Rect>,
@ -89,179 +69,9 @@ pub struct TextEditor {
pub(crate) previous_content: String, pub(crate) previous_content: String,
pub(crate) previous_cursor_char_index: Option<usize>, pub(crate) previous_cursor_char_index: Option<usize>,
pub(crate) current_cursor_line: usize, pub(crate) current_cursor_line: usize,
pub(crate) current_cursor_index: usize,
pub(crate) previous_cursor_line: usize, pub(crate) previous_cursor_line: usize,
pub(crate) font_settings_changed: bool, pub(crate) font_settings_changed: bool,
pub(crate) text_needs_processing: bool, pub(crate) text_needs_processing: bool,
pub(crate) should_select_current_match: bool, pub(crate) should_select_current_match: bool,
pub(crate) previous_cursor_position: Option<usize>, pub(crate) previous_cursor_position: Option<usize>,
pub(crate) markdown_cache: CommonMarkCache,
pub(crate) focus_mode: bool,
pub(crate) focus_manager: FocusManager,
pub(crate) shell_state: ShellState,
}
impl TextEditor {
pub fn perform_action(&mut self, action: ShortcutAction) -> bool {
match action {
ShortcutAction::NewFile => {
io::new_file(self);
false
}
ShortcutAction::OpenFile => {
io::open_file(self);
false
}
ShortcutAction::SaveFile => {
io::save_file(self);
false
}
ShortcutAction::SaveAsFile => {
io::save_as_file(self);
false
}
ShortcutAction::NewTab => {
self.add_new_tab();
false
}
ShortcutAction::CloseTab => {
if self.tabs.len() > 1 {
if let Some(current_tab) = self.get_active_tab() {
if current_tab.is_modified {
self.pending_unsaved_action =
Some(UnsavedAction::CloseTab(self.active_tab_index));
} else {
self.close_tab(self.active_tab_index);
}
}
}
false
}
ShortcutAction::ToggleLineNumbers => {
self.show_line_numbers = !self.show_line_numbers;
self.save_config();
false
}
ShortcutAction::ToggleLineSide => {
self.line_side = !self.line_side;
self.save_config();
false
}
ShortcutAction::ToggleWordWrap => {
self.word_wrap = !self.word_wrap;
self.save_config();
false
}
ShortcutAction::ToggleAutoHideToolbar => {
self.auto_hide_toolbar = !self.auto_hide_toolbar;
self.save_config();
false
}
ShortcutAction::ToggleBottomBar => {
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
}
ShortcutAction::NextTab => {
let next_tab_index = self.active_tab_index + 1;
if next_tab_index < self.tabs.len() {
self.switch_to_tab(next_tab_index);
} else {
self.switch_to_tab(0);
}
false
}
ShortcutAction::PrevTab => {
if self.active_tab_index == 0 {
self.switch_to_tab(self.tabs.len() - 1);
} else {
self.switch_to_tab(self.active_tab_index - 1);
}
false
}
ShortcutAction::PageUp | ShortcutAction::PageDown => false,
ShortcutAction::ZoomIn => {
self.font_size += 1.0;
true
}
ShortcutAction::ZoomOut => {
self.font_size -= 1.0;
true
}
ShortcutAction::GlobalZoomIn => {
self.zoom_factor += 0.1;
false
}
ShortcutAction::GlobalZoomOut => {
self.zoom_factor -= 0.1;
if self.zoom_factor < 0.1 {
self.zoom_factor = 0.1;
}
false
}
ShortcutAction::ResetZoom => {
self.zoom_factor = 1.0;
false
}
ShortcutAction::ToggleVimMode => {
// self.vim_mode = !self.vim_mode;
false
}
ShortcutAction::Escape => {
self.show_about = false;
self.show_shortcuts = false;
if self.show_find {
self.should_select_current_match = true;
}
self.show_find = false;
self.show_preferences = false;
self.pending_unsaved_action = None;
false
}
ShortcutAction::ToggleFind => {
self.show_find = !self.show_find;
if self.show_find && !self.find_query.is_empty() {
self.update_find_matches();
}
false
}
ShortcutAction::ToggleReplace => {
self.show_find = !self.show_find;
self.show_replace_section = true;
if self.show_find && !self.find_query.is_empty() {
self.update_find_matches();
}
false
}
ShortcutAction::FocusFind => {
if self.show_find {
self.focus_find = true;
}
false
}
ShortcutAction::Preferences => {
self.show_preferences = !self.show_preferences;
false
}
ShortcutAction::ToggleMarkdown => {
self.show_markdown = !self.show_markdown;
false
}
ShortcutAction::ToggleFocusMode => {
self.focus_mode = !self.focus_mode;
self.save_config();
true
}
}
}
} }

View File

@ -1,5 +1,4 @@
use super::editor::TextEditor; use super::editor::TextEditor;
use crate::util::safe_slice_to_pos;
use eframe::egui; use eframe::egui;
impl TextEditor { impl TextEditor {
@ -115,15 +114,10 @@ impl TextEditor {
if let Some(active_tab) = self.get_active_tab() { if let Some(active_tab) = self.get_active_tab() {
let content = &active_tab.content; let content = &active_tab.content;
let start_char = safe_slice_to_pos(content, start_byte).chars().count(); let start_char = Self::safe_slice_to_pos(content, start_byte).chars().count();
let end_char = safe_slice_to_pos(content, end_byte).chars().count(); let end_char = Self::safe_slice_to_pos(content, end_byte).chars().count();
let id_source = active_tab let text_edit_id = egui::Id::new("main_text_editor");
.file_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "untitled".to_string());
let text_edit_id = egui::Id::new("main_text_editor").with(&id_source);
if let Some(mut state) = egui::TextEdit::load_state(ctx, text_edit_id) { if let Some(mut state) = egui::TextEdit::load_state(ctx, text_edit_id) {
let selection_range = egui::text::CCursorRange::two( let selection_range = egui::text::CCursorRange::two(
egui::text::CCursor::new(start_char), egui::text::CCursor::new(start_char),
@ -158,16 +152,12 @@ impl TextEditor {
self.update_find_matches(); self.update_find_matches();
if let Some(active_tab) = self.get_active_tab() { if let Some(active_tab) = self.get_active_tab() {
let replacement_end_char = safe_slice_to_pos(&active_tab.content, replacement_end) let replacement_end_char =
Self::safe_slice_to_pos(&active_tab.content, replacement_end)
.chars() .chars()
.count(); .count();
let id_source = active_tab let text_edit_id = egui::Id::new("main_text_editor");
.file_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "untitled".to_string());
let text_edit_id = egui::Id::new("main_text_editor").with(&id_source);
if let Some(mut state) = egui::TextEdit::load_state(ctx, text_edit_id) { if let Some(mut state) = egui::TextEdit::load_state(ctx, text_edit_id) {
state state
.cursor .cursor
@ -216,14 +206,7 @@ impl TextEditor {
self.current_match_index = None; self.current_match_index = None;
if let Some(active_tab) = self.get_active_tab() { let text_edit_id = egui::Id::new("main_text_editor");
let id_source = active_tab
.file_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "untitled".to_string());
let text_edit_id = egui::Id::new("main_text_editor").with(&id_source);
if let Some(mut state) = egui::TextEdit::load_state(ctx, text_edit_id) { if let Some(mut state) = egui::TextEdit::load_state(ctx, text_edit_id) {
state state
.cursor .cursor
@ -234,4 +217,3 @@ impl TextEditor {
} }
} }
} }
}

View File

@ -1,8 +1,16 @@
use super::editor::{TextEditor, TextProcessingResult}; use super::editor::{TextEditor, TextProcessingResult};
use crate::util::safe_slice_to_pos;
use eframe::egui; use eframe::egui;
impl TextEditor { impl TextEditor {
pub(crate) fn safe_slice_to_pos(content: &str, pos: usize) -> &str {
let pos = pos.min(content.len());
let mut boundary_pos = pos;
while boundary_pos > 0 && !content.is_char_boundary(boundary_pos) {
boundary_pos -= 1;
}
&content[..boundary_pos]
}
pub fn process_text_for_rendering(&mut self, content: &str, ui: &egui::Ui) { pub fn process_text_for_rendering(&mut self, content: &str, ui: &egui::Ui) {
let line_count = content.bytes().filter(|&b| b == b'\n').count() + 1; let line_count = content.bytes().filter(|&b| b == b'\n').count() + 1;
@ -42,12 +50,13 @@ impl TextEditor {
let font_id = self.get_font_id(); let font_id = self.get_font_id();
let longest_line_pixel_width = if longest_line_length > 0 { let longest_line_pixel_width = if longest_line_length > 0 {
let longest_line_text = lines[longest_line_index]; let longest_line_text = lines[longest_line_index];
ui.fonts_mut(|fonts| { ui.fonts(|fonts| {
fonts fonts
.layout_no_wrap( .layout(
longest_line_text.to_string(), longest_line_text.to_string(),
font_id, font_id,
egui::Color32::WHITE, egui::Color32::WHITE,
f32::INFINITY,
) )
.size() .size()
.x .x
@ -74,6 +83,15 @@ impl TextEditor {
new_cursor_pos: usize, new_cursor_pos: usize,
ui: &egui::Ui, ui: &egui::Ui,
) { ) {
let line_change = self.calculate_cursor_line_change(
old_content,
new_content,
old_cursor_pos,
new_cursor_pos,
);
self.current_cursor_line = (self.current_cursor_line as isize + line_change) as usize;
if old_content.len() == new_content.len() { if old_content.len() == new_content.len() {
self.handle_character_replacement( self.handle_character_replacement(
old_content, old_content,
@ -103,6 +121,25 @@ impl TextEditor {
self.previous_cursor_line = self.current_cursor_line; 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 = Self::safe_slice_to_pos(old_content, old_cursor_pos)
.bytes()
.filter(|&b| b == b'\n')
.count();
let new_newlines = Self::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( fn handle_character_replacement(
&mut self, &mut self,
@ -131,43 +168,41 @@ impl TextEditor {
new_cursor_pos: usize, new_cursor_pos: usize,
ui: &egui::Ui, ui: &egui::Ui,
) { ) {
let old_char_count = old_content.chars().count(); let min_len = old_content.len().min(new_content.len());
let new_char_count = new_content.chars().count(); let mut common_prefix = 0;
let mut common_suffix = 0;
let safe_new_cursor = new_cursor_pos.min(new_char_count); for i in 0..min_len {
if old_content.as_bytes()[i] == new_content.as_bytes()[i] {
let new_byte_pos = new_content.char_indices() common_prefix += 1;
.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::<usize>()
);
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 { } 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 newlines_added = added_text.bytes().filter(|&b| b == b'\n').count(); let newlines_added = added_text.bytes().filter(|&b| b == b'\n').count();
if newlines_added > 0 { if newlines_added > 0 {
let mut current_result = self.get_text_processing_result(); let mut current_result = self.get_text_processing_result();
current_result.line_count += newlines_added; current_result.line_count += newlines_added;
let addition_start_line = new_content[..addition_start_byte] let addition_start_line = Self::safe_slice_to_pos(old_content, added_start)
.bytes() .bytes()
.filter(|&b| b == b'\n') .filter(|&b| b == b'\n')
.count(); .count();
let addition_end_line = new_content[..addition_end_byte] let addition_end_line = Self::safe_slice_to_pos(old_content, added_end)
.bytes() .bytes()
.filter(|&b| b == b'\n') .filter(|&b| b == b'\n')
.count(); .count();
@ -183,7 +218,7 @@ impl TextEditor {
self.update_processing_result(current_result); self.update_processing_result(current_result);
} }
} else { } else {
let current_line = self.extract_current_line(new_content, safe_new_cursor); let current_line = self.extract_current_line(new_content, new_cursor_pos);
let current_line_length = current_line.chars().count(); let current_line_length = current_line.chars().count();
self.update_line_if_longer( self.update_line_if_longer(
self.current_cursor_line, self.current_cursor_line,
@ -233,11 +268,11 @@ impl TextEditor {
let mut current_result = self.get_text_processing_result(); let mut current_result = self.get_text_processing_result();
current_result.line_count = current_result.line_count.saturating_sub(newlines_removed); current_result.line_count = current_result.line_count.saturating_sub(newlines_removed);
let removal_start_line = safe_slice_to_pos(old_content, removed_start) let removal_start_line = Self::safe_slice_to_pos(old_content, removed_start)
.bytes() .bytes()
.filter(|&b| b == b'\n') .filter(|&b| b == b'\n')
.count(); .count();
let removal_end_line = safe_slice_to_pos(old_content, removed_end) let removal_end_line = Self::safe_slice_to_pos(old_content, removed_end)
.bytes() .bytes()
.filter(|&b| b == b'\n') .filter(|&b| b == b'\n')
.count(); .count();
@ -296,7 +331,7 @@ impl TextEditor {
{ {
content[line_start_boundary..line_end_boundary].to_string() content[line_start_boundary..line_end_boundary].to_string()
} else { } else {
safe_slice_to_pos(content, line_end_boundary)[line_start_boundary..].to_string() Self::safe_slice_to_pos(content, line_end_boundary)[line_start_boundary..].to_string()
} }
} }
@ -311,12 +346,13 @@ impl TextEditor {
if line_length > current_result.longest_line_length { if line_length > current_result.longest_line_length {
let font_id = self.get_font_id(); let font_id = self.get_font_id();
let pixel_width = ui.fonts_mut(|fonts| { let pixel_width = ui.fonts(|fonts| {
fonts fonts
.layout_no_wrap( .layout(
line_content.to_string(), line_content.to_string(),
font_id, font_id,
egui::Color32::WHITE, egui::Color32::WHITE,
f32::INFINITY,
) )
.size() .size()
.x .x

View File

@ -36,7 +36,7 @@ impl TextEditor {
self.update_find_matches(); self.update_find_matches();
} }
self.text_needs_processing = true; 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() { if let Err(e) = self.save_state_cache() {
eprintln!("Failed to save state cache: {e}"); eprintln!("Failed to save state cache: {e}");
} }
@ -49,10 +49,7 @@ impl TextEditor {
if self.show_find && !self.find_query.is_empty() { if self.show_find && !self.find_query.is_empty() {
self.update_find_matches(); 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.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() { if let Err(e) = self.save_state_cache() {
eprintln!("Failed to save state cache: {e}"); eprintln!("Failed to save state cache: {e}");

View File

@ -46,7 +46,6 @@ impl TextEditor {
egui::TextStyle::Monospace, egui::TextStyle::Monospace,
egui::FontId::new(self.font_size, font_family), egui::FontId::new(self.font_size, font_family),
); );
self.font_size_input = Some(self.font_size.to_string());
ctx.set_style(style); ctx.set_style(style);
self.font_settings_changed = true; self.font_settings_changed = true;
@ -66,18 +65,18 @@ impl TextEditor {
let processing_result = self.get_text_processing_result(); let processing_result = self.get_text_processing_result();
let line_count = processing_result.line_count; let line_count = processing_result.line_count;
let monospace_font_id = egui::FontId::monospace(self.font_size); let font_id = self.get_font_id();
let line_count_digits = line_count.to_string().len(); let line_count_digits = line_count.to_string().len();
let sample_text = "9".repeat(line_count_digits); let sample_text = "9".repeat(line_count_digits);
let base_line_number_width = ui.fonts_mut(|fonts| { let base_line_number_width = ui.fonts(|fonts| {
fonts fonts
.layout_no_wrap(sample_text, monospace_font_id, egui::Color32::WHITE) .layout(sample_text, font_id, egui::Color32::WHITE, f32::INFINITY)
.size() .size()
.x .x
}); });
let line_number_width = if self.line_side { let line_number_width = if self.line_side {
base_line_number_width + crate::ui::constants::SCROLLBAR_WIDTH base_line_number_width + 25.0 // Scrollbar width
} else { } else {
base_line_number_width base_line_number_width
}; };
@ -96,149 +95,10 @@ impl TextEditor {
return self.calculate_editor_dimensions(ui).text_width; return self.calculate_editor_dimensions(ui).text_width;
} }
let longest_line_width = processing_result.longest_line_pixel_width; let longest_line_width =
let font_id = self.get_font_id(); processing_result.longest_line_pixel_width + (self.font_size * 3.0);
let char_width = ui.fonts_mut(|fonts| {
fonts
.layout_no_wrap("M".to_string(), font_id, egui::Color32::WHITE)
.size()
.x
});
let extra_space = char_width * 2.0;
let dimensions = self.calculate_editor_dimensions(ui); let dimensions = self.calculate_editor_dimensions(ui);
(longest_line_width + extra_space).max(dimensions.text_width) longest_line_width.max(dimensions.text_width)
}
pub fn get_cursor_position(&self) -> (usize, usize) {
if let Some(active_tab) = self.get_active_tab() {
let content = &active_tab.content;
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[..byte_pos].chars().rev() {
if c == '\n' {
break;
}
column += 1;
}
(column + 1, self.current_cursor_line)
} else {
(0, 0)
} }
} }
pub fn handle_page_movement(&mut self, ctx: &egui::Context, direction_down: bool) {
let Some(active_tab) = self.get_active_tab() else {
return;
};
let id_source = active_tab
.file_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "untitled".to_string());
let text_edit_id = egui::Id::new("main_text_editor").with(&id_source);
let Some(mut state) = egui::TextEdit::load_state(ctx, text_edit_id) else {
return;
};
let Some(cursor_range) = state.cursor.char_range() else {
return;
};
let current_pos = cursor_range.primary.index;
let content = &active_tab.content;
let available_height = ctx.available_rect().height();
let row_height = self.font_size * 1.5;
let visible_rows = (available_height / row_height).floor() as usize;
let rows_to_move = visible_rows.max(1);
let new_pos = if direction_down {
move_cursor_down_lines(content, current_pos, rows_to_move)
} else {
move_cursor_up_lines(content, current_pos, rows_to_move)
};
let new_cursor = egui::text::CCursor::new(new_pos);
state
.cursor
.set_char_range(Some(egui::text::CCursorRange::one(new_cursor)));
egui::TextEdit::store_state(ctx, text_edit_id, state);
}
}
fn move_cursor_down_lines(content: &str, current_pos: usize, lines: usize) -> usize {
let char_count = content.chars().count();
let safe_char_pos = current_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());
let mut result_byte_pos = byte_pos;
let mut lines_moved = 0;
// 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 {
result_byte_pos = byte_pos + idx + 1;
break;
}
}
}
if lines_moved < lines && result_byte_pos == byte_pos {
result_byte_pos = 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 char_count = content.chars().count();
let safe_char_pos = current_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());
let mut result_byte_pos = byte_pos;
let mut lines_moved = 0;
// 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;
}
// Convert byte index back to character index
content[..result_byte_pos].chars().count()
}

View File

@ -250,49 +250,3 @@ 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(())
}

View File

@ -8,8 +8,7 @@ use std::path::PathBuf;
mod app; mod app;
mod io; mod io;
mod ui; mod ui;
mod util; use app::{config::Config, TextEditor};
use app::{TextEditor, config::Config};
fn main() -> eframe::Result { fn main() -> eframe::Result {
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();

View File

@ -1,12 +1,8 @@
pub(crate) mod about_window; pub(crate) mod about_window;
pub(crate) mod bottom_bar;
pub(crate) mod central_panel; pub(crate) mod central_panel;
pub(crate) mod constants; pub(crate) mod constants;
pub(crate) mod find_window; pub(crate) mod find_window;
pub(crate) mod file_tree;
pub(crate) mod focus_manager;
pub(crate) mod menu_bar; pub(crate) mod menu_bar;
pub(crate) mod preferences_window; pub(crate) mod preferences_window;
pub(crate) mod shortcuts_window; pub(crate) mod shortcuts_window;
pub(crate) mod tab_bar; pub(crate) mod tab_bar;
pub(crate) mod shell_bar;

View File

@ -1,43 +0,0 @@
use crate::app::TextEditor;
use crate::ui::central_panel::languages::get_language_from_extension;
use eframe::egui::{self, Frame};
pub(crate) fn bottom_bar(app: &mut TextEditor, ctx: &egui::Context) {
let line_count = app.get_text_processing_result().line_count;
let char_count = app
.get_active_tab()
.map(|tab| tab.content.chars().count())
.unwrap_or(0);
let cursor_position = app.get_cursor_position();
let cursor_column = cursor_position.0;
let cursor_row = cursor_position.1;
let active_tab = app.get_active_tab();
let file_path = active_tab.and_then(|tab| tab.file_path.as_deref());
let language = get_language_from_extension(file_path);
if app.show_bottom_bar {
let frame = Frame::NONE.fill(ctx.style().visuals.panel_fill);
egui::TopBottomPanel::bottom("bottom_bar")
.frame(frame)
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.add_space(8.0);
ui.label(format!("Ln {}, Col {}", cursor_row, cursor_column));
ui.separator();
ui.label(format!("{} chars, {} lines", char_count, line_count));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(8.0);
ui.label(format!("{}", language.to_uppercase()));
ui.separator();
ui.label("UTF-8");
ui.separator();
ui.label(format!("{}pt", app.font_size as u32));
});
});
});
}
}

View File

@ -1,35 +1,22 @@
mod editor; mod editor;
mod find_highlight; mod find_highlight;
pub mod languages; mod languages;
mod line_numbers; mod line_numbers;
mod markdown;
use crate::app::TextEditor; use crate::app::TextEditor;
use crate::ui::constants::*; use crate::ui::constants::*;
use eframe::egui; use eframe::egui;
use egui::UiKind;
use self::editor::editor_view_ui; use self::editor::editor_view_ui;
use self::languages::get_language_from_extension;
use self::line_numbers::{calculate_visual_line_mapping, render_line_numbers}; use self::line_numbers::{calculate_visual_line_mapping, render_line_numbers};
use self::markdown::markdown_view_ui;
fn is_markdown_tab(app: &TextEditor) -> bool {
app.get_active_tab()
.and_then(|tab| tab.file_path.as_deref())
.map(|path| get_language_from_extension(Some(path)) == "md")
.unwrap_or(false)
}
pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
let show_line_numbers = app.show_line_numbers; let show_line_numbers = app.show_line_numbers;
let word_wrap = app.word_wrap; let word_wrap = app.word_wrap;
let line_side = app.line_side; let line_side = app.line_side;
let font_size = app.font_size; let font_size = app.font_size;
let monospace = if app.font_family.as_str() == "Monospace" { true } else { false };
let font_id = app.get_font_id(); 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() let _output = egui::CentralPanel::default()
.frame(egui::Frame::NONE) .frame(egui::Frame::NONE)
@ -39,81 +26,55 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
ui.painter().rect_filled(panel_rect, 0.0, bg_color); ui.painter().rect_filled(panel_rect, 0.0, bg_color);
let editor_height = panel_rect.height(); let editor_height = panel_rect.height();
// Handle markdown split view if !show_line_numbers || app.get_active_tab().is_none() {
if show_markdown && is_markdown_file {
let half_width = panel_rect.width() / 2.0;
ui.push_id("markdown_split_container", |ui| {
ui.allocate_ui_with_layout(
egui::vec2(panel_rect.width(), editor_height),
egui::Layout::left_to_right(egui::Align::TOP),
|ui| {
// Left side: Editor
ui.allocate_ui_with_layout(
egui::vec2(half_width, editor_height),
egui::Layout::top_down(egui::Align::LEFT),
|ui| {
egui::ScrollArea::vertical()
.id_salt("editor_scroll_area")
.auto_shrink([false; 2])
.show(ui, |ui| {
editor_view_ui(ui, app);
});
},
);
// Separator
let separator_x = ui.cursor().left();
let mut y_range = ui.available_rect_before_wrap().y_range();
y_range.max += 2.0 * font_size;
ui.painter()
.vline(separator_x, y_range, ui.visuals().window_stroke);
ui.add_space(SMALL);
// Right side: Markdown view
ui.allocate_ui_with_layout(
egui::vec2(half_width, editor_height),
egui::Layout::top_down(egui::Align::LEFT),
|ui| {
egui::ScrollArea::vertical()
.id_salt("markdown_scroll_area")
.auto_shrink([false; 2])
.show(ui, |ui| {
egui::Frame::new()
.inner_margin(egui::Margin {
left: 0,
right: SCROLLBAR_WIDTH as i8,
top: 0,
bottom: 0,
})
.show(ui, |ui| {
markdown_view_ui(ui, app);
});
});
},
);
},
);
});
return;
}
if !show_line_numbers || app.get_active_tab().is_none() || focus_mode {
let _scroll_response = let _scroll_response =
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false; 2]) .auto_shrink([false; 2])
.show(ui, |ui| { .show(ui, |ui| {
let full_rect = ui.available_rect_before_wrap(); let full_rect = ui.available_rect_before_wrap();
let context_response =
ui.allocate_response(full_rect.size(), egui::Sense::click());
ui.scope_builder(egui::UiBuilder::new().max_rect(full_rect), |ui| { ui.scope_builder(egui::UiBuilder::new().max_rect(full_rect), |ui| {
editor_view_ui(ui, app); editor_view_ui(ui, app);
}); });
handle_empty(ui, app, &context_response);
}); });
return; return;
} }
let line_count = app.get_text_processing_result().line_count; let line_count = app.get_text_processing_result().line_count;
let line_number_width = app.calculate_editor_dimensions(ui).line_number_width; let editor_dimensions = app.calculate_editor_dimensions(ui);
let line_number_width = editor_dimensions.line_number_width;
let editor_width = editor_dimensions.text_width - line_number_width;
let visual_line_mapping = if word_wrap {
app.get_active_tab()
.map(|active_tab| {
let actual_editor_width = ui.available_width() - line_number_width;
calculate_visual_line_mapping(
ui,
&active_tab.content,
actual_editor_width,
font_id,
)
})
.unwrap_or_else(Vec::new)
} else {
Vec::new()
};
let line_numbers_widget = |ui: &mut egui::Ui| {
render_line_numbers(
ui,
line_count,
&visual_line_mapping,
line_number_width,
word_wrap,
line_side,
font_size,
);
};
let separator_widget = |ui: &mut egui::Ui| { let separator_widget = |ui: &mut egui::Ui| {
let separator_x = ui.cursor().left(); let separator_x = ui.cursor().left();
@ -127,51 +88,29 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false; 2]) .auto_shrink([false; 2])
.show(ui, |ui| { .show(ui, |ui| {
let available_width = ui.available_width();
let actual_editor_width = (available_width - line_number_width).max(0.0);
let visual_line_mapping = if word_wrap {
app.get_active_tab()
.map(|active_tab| {
calculate_visual_line_mapping(
ui,
&active_tab.content,
actual_editor_width - (if line_side { 8.0 } else { 20.0 }),
font_id,
)
})
.unwrap_or_default()
} else {
Vec::new()
};
let line_numbers_widget = |ui: &mut egui::Ui| {
render_line_numbers(
ui,
line_count,
&visual_line_mapping,
line_number_width,
word_wrap,
line_side,
font_size,
monospace,
);
};
if line_side { if line_side {
ui.allocate_ui_with_layout( ui.allocate_ui_with_layout(
egui::vec2(available_width, editor_height), egui::vec2(editor_dimensions.text_width, editor_height),
egui::Layout::left_to_right(egui::Align::TOP), egui::Layout::left_to_right(egui::Align::TOP),
|ui| { |ui| {
ui.allocate_ui_with_layout( ui.allocate_ui_with_layout(
egui::vec2(actual_editor_width, editor_height), egui::vec2(editor_width, editor_height),
egui::Layout::left_to_right(egui::Align::TOP), egui::Layout::left_to_right(egui::Align::TOP),
|ui| { |ui| {
let full_rect: egui::Rect = ui.available_rect_before_wrap(); 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( ui.scope_builder(
egui::UiBuilder::new().max_rect(full_rect), egui::UiBuilder::new().max_rect(full_rect),
|ui| editor_view_ui(ui, app), |ui| {
editor_view_ui(ui, app);
},
); );
handle_empty(ui, app, &context_response);
}, },
); );
separator_widget(ui); separator_widget(ui);
@ -180,17 +119,24 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
); );
} else { } else {
ui.allocate_ui_with_layout( ui.allocate_ui_with_layout(
egui::vec2(available_width, editor_height), egui::vec2(editor_dimensions.text_width, editor_height),
egui::Layout::left_to_right(egui::Align::TOP), egui::Layout::left_to_right(egui::Align::TOP),
|ui| { |ui| {
line_numbers_widget(ui); line_numbers_widget(ui);
separator_widget(ui); separator_widget(ui);
let editor_area = ui.available_rect_before_wrap(); let editor_area = ui.available_rect_before_wrap();
let context_response =
ui.allocate_response(editor_area.size(), egui::Sense::click());
ui.scope_builder( ui.scope_builder(
egui::UiBuilder::new().max_rect(editor_area), egui::UiBuilder::new().max_rect(editor_area),
|ui| editor_view_ui(ui, app), |ui| {
editor_view_ui(ui, app);
},
); );
handle_empty(ui, app, &context_response);
}, },
); );
} }
@ -198,3 +144,74 @@ 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) {
if context_response.clicked() {
let text_edit_id = egui::Id::new("main_text_editor");
if let Some(mut state) = egui::TextEdit::load_state(_ui.ctx(), text_edit_id) {
if let Some(active_tab) = app.get_active_tab() {
let text_len = active_tab.content.len();
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);
});
}
}
}
context_response.context_menu(|ui| {
let text_len = app.get_active_tab().unwrap().content.len();
let reset_zoom_key = egui::Id::new("editor_reset_zoom");
if ui.button("Cut").clicked() {
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::RequestCut);
ui.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 = egui::Id::new("main_text_editor");
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) {
let select_all_range = egui::text::CCursorRange::two(
egui::text::CCursor::new(0),
egui::text::CCursor::new(text_len),
);
state.cursor.set_char_range(Some(select_all_range));
egui::TextEdit::store_state(ui.ctx(), text_edit_id, state);
}
ui.close_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);
}
});
}

View File

@ -1,36 +1,37 @@
use crate::app::TextEditor; use crate::app::TextEditor;
use crate::ui::focus_manager::{FocusTarget, priorities};
use eframe::egui; use eframe::egui;
use egui_extras::syntax_highlighting::{self}; use egui_extras::syntax_highlighting::{self};
use super::find_highlight; use super::find_highlight;
pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::Response { pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::Response {
let _current_match_position = app.get_current_match_position();
let show_find = app.show_find; let show_find = app.show_find;
let _prev_show_find = app.prev_show_find;
let show_preferences = app.show_preferences; let show_preferences = app.show_preferences;
let show_about = app.show_about; let show_about = app.show_about;
let show_shortcuts = app.show_shortcuts; let show_shortcuts = app.show_shortcuts;
let 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 word_wrap = app.word_wrap;
let font_size = app.font_size; let font_size = app.font_size;
let font_id = app.get_font_id(); let font_id = app.get_font_id();
let syntax_highlighting_enabled = app.syntax_highlighting; 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 bg_color = ui.visuals().extreme_bg_color;
let editor_rect = ui.available_rect_before_wrap(); let editor_rect = ui.available_rect_before_wrap();
ui.painter().rect_filled(editor_rect, 0.0, bg_color); ui.painter().rect_filled(editor_rect, 0.0, bg_color);
handle_zoom_reset(ui, app); let reset_zoom_key = egui::Id::new("editor_reset_zoom");
let should_reset_zoom = ui
.ctx()
.memory_mut(|mem| mem.data.get_temp::<bool>(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);
});
}
let (estimated_width, desired_width) = if !word_wrap { let (estimated_width, desired_width) = if !word_wrap {
(app.calculate_content_based_width(ui), f32::INFINITY) (app.calculate_content_based_width(ui), f32::INFINITY)
@ -54,355 +55,18 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R
return ui.label("No file open, how did you get here?"); return ui.label("No file open, how did you get here?");
}; };
let draw_highlights = |ui: &mut egui::Ui| { if let Some((content, matches, current_match_index)) = &find_data {
draw_editor_highlights(ui, &find_data, &font_id, font_size, desired_width); let temp_galley = ui.fonts(|fonts| {
};
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 layout_job = editor_layouter(
ui,
string,
wrap_width,
syntax_highlighting_enabled,
&language,
&font_id,
);
ui.fonts_mut(|f| f.layout_job(layout_job))
};
let id_source = active_tab
.file_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.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)
|| i.pointer.button_down(egui::PointerButton::Middle)
});
let text_edit = egui::TextEdit::multiline(&mut active_tab.content)
.frame(false)
.code_editor()
.desired_width(desired_width)
.desired_rows(0)
.lock_focus(!show_find)
.cursor_at_end(false)
.layouter(&mut layouter)
.interactive(allow_interaction)
.id(text_edit_id);
let output = if word_wrap {
draw_highlights(ui);
text_edit.show(ui)
} else {
egui::ScrollArea::horizontal()
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.allocate_ui_with_layout(
egui::Vec2::new(estimated_width, ui.available_height()),
egui::Layout::left_to_right(egui::Align::TOP),
|ui| {
draw_highlights(ui);
let output = text_edit.show(ui);
ensure_cursor_visible(ui, &output, &font_id, previous_cursor_position);
output
},
)
})
.inner
.inner
};
ensure_cursor_visible(ui, &output, &font_id, previous_cursor_position);
handle_post_render_updates(ui, app, &output, indent_result, tab_result);
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);
}
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::<bool>(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<usize>,
) {
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();
// 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));
}
}
}
}
}
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 {
if let Some(active_tab) = app.get_active_tab() {
let content = active_tab.content.to_owned();
app.process_text_for_rendering(&content, ui);
}
app.font_settings_changed = false;
app.text_needs_processing = false;
}
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);
}
}
fn handle_content_change(
app: &mut TextEditor,
current_cursor_pos: Option<usize>,
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();
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<usize>)>,
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( fonts.layout(
content.to_owned(), content.to_owned(),
font_id.to_owned(), font_id.to_owned(),
ui.visuals().text_color(), ui.visuals().text_color(),
wrap_width - 8.0, desired_width,
) )
}); });
let cursor_pos = ui.cursor().min; let text_area_left = editor_rect.left() + 4.0; // Text Editor default margins
let text_area_left = cursor_pos.x + 4.0; // Text Editor default margins let text_area_top = editor_rect.top() + 2.0;
let text_area_top = cursor_pos.y + 2.0;
find_highlight::draw_find_highlights( find_highlight::draw_find_highlights(
ui, ui,
@ -415,157 +79,159 @@ fn draw_editor_highlights(
font_size, font_size,
); );
} }
}
fn editor_layouter( let language = super::languages::get_language_from_extension(active_tab.file_path.as_deref());
ui: &egui::Ui, let mut layouter = |ui: &egui::Ui, string: &dyn egui::TextBuffer, wrap_width: f32| {
string: &dyn egui::TextBuffer, // let syntect_theme =
wrap_width: f32, // crate::app::theme::create_code_theme_from_visuals(ui.visuals(), font_size);
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 theme = egui_extras::syntax_highlighting::CodeTheme::from_style(ui.style());
let text = string.as_str(); let text = string.as_str();
let mut layout_job = if syntax_highlighting_enabled && language != "txt" { let mut layout_job = if syntax_highlighting_enabled && language != "txt" {
syntax_highlighting::highlight(ui.ctx(), &ui.style().clone(), &theme, text, language) // 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 { } else {
syntax_highlighting::highlight(ui.ctx(), &ui.style().clone(), &theme, text, "") syntax_highlighting::highlight(ui.ctx(), &ui.style().clone(), &theme, text, "")
}; };
if syntax_highlighting_enabled && language != "txt" { if syntax_highlighting_enabled && language != "txt" {
for section in &mut layout_job.sections { for section in &mut layout_job.sections {
section.format.font_id = font_id.to_owned(); section.format.font_id = font_id.clone();
} }
} }
layout_job.wrap.max_width = wrap_width; layout_job.wrap.max_width = wrap_width;
layout_job ui.fonts(|f| f.layout_job(layout_job))
} };
fn handle_auto_indent( let text_edit = egui::TextEdit::multiline(&mut active_tab.content)
ui: &mut egui::Ui, .frame(false)
active_tab: &mut crate::app::tab::Tab, .code_editor()
text_edit_id: egui::Id, .desired_width(desired_width)
auto_indent: bool, .desired_rows(0)
) -> bool { .lock_focus(!show_find)
if !auto_indent || !ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.is_none()) { .cursor_at_end(false)
return false; .layouter(&mut layouter)
} .id(egui::Id::new("main_text_editor"));
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { let output = if word_wrap {
if let Some(cursor_range) = state.cursor.char_range() { text_edit.show(ui)
let cursor_pos = cursor_range.primary.index; } else {
let content = &active_tab.content; egui::ScrollArea::horizontal()
.auto_shrink([false; 2])
// Find previous line's indentation .show(ui, |ui| {
let byte_idx = content ui.allocate_ui_with_layout(
.char_indices() egui::Vec2::new(estimated_width, ui.available_height()),
.nth(cursor_pos) egui::Layout::left_to_right(egui::Align::TOP),
.map(|(i, _)| i) |ui| text_edit.show(ui),
.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,
..
}
) )
}) })
}); .inner
.inner
};
return true; 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
};
if content_changed {
if let Err(e) = app.save_state_cache() {
eprintln!("Failed to save state cache: {e}");
} }
} }
false
}
fn handle_tab_insertion( if content_changed && app.show_find && !app.find_query.is_empty() {
ui: &mut egui::Ui, app.update_find_matches();
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) { let current_cursor_pos = output
if let Some(cursor_range) = state.cursor.char_range() { .state
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 .cursor
.set_char_range(Some(egui::text::CCursorRange::one( .char_range()
egui::text::CCursor::new(new_pos), .map(|range| range.primary.index);
)));
egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); if let Some(content) = content_for_processing {
ui.input_mut(|i| { let previous_content = app.previous_content.to_owned();
i.events.retain(|e| { let previous_cursor_pos = app.previous_cursor_char_index;
!matches!(
e, if !previous_content.is_empty() {
egui::Event::Key { if let (Some(prev_cursor_pos), Some(curr_cursor_pos)) =
key: egui::Key::Tab, (previous_cursor_pos, current_cursor_pos)
pressed: true, {
.. 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.to_owned();
app.previous_cursor_char_index = current_cursor_pos;
}
if app.font_settings_changed || app.text_needs_processing {
if let Some(active_tab) = app.get_active_tab() {
let content = active_tab.content.to_owned();
app.process_text_for_rendering(&content, ui);
}
app.font_settings_changed = false;
app.text_needs_processing = false;
}
if let Some(cursor_pos) = current_cursor_pos {
let cursor_moved = Some(cursor_pos) != app.previous_cursor_position;
let text_changed = output.response.changed();
if cursor_moved || text_changed {
if let Some(active_tab) = app.get_active_tab() {
let content = &active_tab.content;
let cursor_line = content
.char_indices()
.take_while(|(byte_pos, _)| *byte_pos < cursor_pos)
.filter(|(_, ch)| *ch == '\n')
.count();
let font_id = ui
.style()
.text_styles
.get(&egui::TextStyle::Monospace)
.unwrap_or(&egui::FontId::monospace(font_size))
.to_owned();
let line_height = ui.fonts(|fonts| fonts.row_height(&font_id));
let y_pos = output.response.rect.top() + (cursor_line as f32 * line_height);
let cursor_rect = egui::Rect::from_min_size(
egui::pos2(output.response.rect.left(), y_pos),
egui::vec2(2.0, line_height),
);
let visible_area = ui.clip_rect();
if !visible_area.intersects(cursor_rect) {
ui.scroll_to_rect(cursor_rect, Some(egui::Align::Center));
} }
} }
true }
app.previous_cursor_position = Some(cursor_pos);
}
if !output.response.has_focus()
&& !show_preferences
&& !show_about
&& !show_shortcuts
&& !show_find
{
output.response.request_focus();
}
output.response
} }

View File

@ -1,6 +1,15 @@
use crate::util::safe_slice_to_pos;
use eframe::egui; use eframe::egui;
/// Safely get a string slice up to a byte position, ensuring UTF-8 boundaries
fn safe_slice_to_pos(content: &str, pos: usize) -> &str {
let pos = pos.min(content.len());
let mut boundary_pos = pos;
while boundary_pos > 0 && !content.is_char_boundary(boundary_pos) {
boundary_pos -= 1;
}
&content[..boundary_pos]
}
pub(super) fn draw_find_highlights( pub(super) fn draw_find_highlights(
ui: &mut egui::Ui, ui: &mut egui::Ui,
content: &str, content: &str,
@ -9,18 +18,26 @@ pub(super) fn draw_find_highlights(
galley: &std::sync::Arc<egui::Galley>, galley: &std::sync::Arc<egui::Galley>,
text_area_left: f32, text_area_left: f32,
text_area_top: f32, text_area_top: f32,
_font_size: f32, font_size: f32,
) { ) {
for (match_index, &(start_byte, end_byte)) in matches.iter().enumerate() { let font_id = ui
.style()
.text_styles
.get(&egui::TextStyle::Monospace)
.unwrap_or(&egui::FontId::monospace(font_size))
.to_owned();
for (match_index, &(start_pos, end_pos)) in matches.iter().enumerate() {
let is_current_match = current_match_index == Some(match_index); let is_current_match = current_match_index == Some(match_index);
draw_single_highlight( draw_single_highlight(
ui, ui,
content, content,
start_byte, start_pos,
end_byte, end_pos,
galley,
text_area_left, text_area_left,
text_area_top, text_area_top,
galley,
&font_id,
is_current_match, is_current_match,
); );
} }
@ -29,15 +46,70 @@ pub(super) fn draw_find_highlights(
fn draw_single_highlight( fn draw_single_highlight(
ui: &mut egui::Ui, ui: &mut egui::Ui,
content: &str, content: &str,
start_byte: usize, start_pos: usize,
end_byte: usize, end_pos: usize,
galley: &std::sync::Arc<egui::Galley>,
text_area_left: f32, text_area_left: f32,
text_area_top: f32, text_area_top: f32,
galley: &std::sync::Arc<egui::Galley>,
font_id: &egui::FontId,
is_current_match: bool, is_current_match: bool,
) { ) {
let start_char = safe_slice_to_pos(content, start_byte).chars().count(); let text_up_to_start = safe_slice_to_pos(content, start_pos);
let end_char = safe_slice_to_pos(content, end_byte).chars().count(); let start_line = text_up_to_start.chars().filter(|&c| c == '\n').count();
if start_line >= galley.rows.len() {
return;
}
let line_start_byte_pos = text_up_to_start.rfind('\n').map(|pos| pos + 1).unwrap_or(0);
let line_start_char_pos = safe_slice_to_pos(content, line_start_byte_pos)
.chars()
.count();
let start_char_pos = safe_slice_to_pos(content, start_pos).chars().count();
let start_col = start_char_pos - line_start_char_pos;
let lines: Vec<&str> = content.lines().collect();
if start_line >= lines.len() {
return;
}
let line_text = lines[start_line];
let text_before_match: String = line_text.chars().take(start_col).collect();
let text_before_width = ui.fonts(|fonts| {
fonts
.layout(
text_before_match,
font_id.to_owned(),
egui::Color32::WHITE,
f32::INFINITY,
)
.size()
.x
});
let galley_row = &galley.rows[start_line];
let start_y = text_area_top + galley_row.min_y();
let line_height = galley_row.height();
let start_x = text_area_left + text_before_width;
let match_text = &content[start_pos..end_pos.min(content.len())];
let match_width = ui.fonts(|fonts| {
fonts
.layout(
match_text.to_string(),
font_id.to_owned(),
ui.visuals().text_color(),
f32::INFINITY,
)
.size()
.x
});
let highlight_rect = egui::Rect::from_min_size(
egui::pos2(start_x, start_y),
egui::vec2(match_width, line_height),
);
let highlight_color = if is_current_match { let highlight_color = if is_current_match {
ui.visuals().selection.bg_fill ui.visuals().selection.bg_fill
@ -46,36 +118,5 @@ fn draw_single_highlight(
}; };
let painter = ui.painter(); let painter = ui.painter();
painter.rect_filled(highlight_rect, 0.0, highlight_color);
let mut current_char_idx = 0;
for row in &galley.rows {
let row_start_char = current_char_idx;
let row_end_char = row_start_char + row.char_count_excluding_newline();
current_char_idx += row.char_count_including_newline();
if row_end_char <= start_char || row_start_char >= end_char {
continue;
}
let highlight_start_char_in_row = start_char.max(row_start_char) - row_start_char;
let highlight_end_char_in_row = end_char.min(row_end_char) - row_start_char;
let start_x = row.x_offset(highlight_start_char_in_row);
let end_x = row.x_offset(highlight_end_char_in_row);
let rect = egui::Rect::from_min_max(
egui::pos2(
text_area_left + row.rect().min.x + start_x,
text_area_top + row.rect().min.y,
),
egui::pos2(
text_area_left + row.rect().min.x + end_x,
text_area_top + row.rect().max.y,
),
);
painter.rect_filled(rect, 0.0, highlight_color);
}
} }

View File

@ -2,10 +2,8 @@ use eframe::egui;
fn format_line_number(line_number: usize, line_side: bool, line_count_width: usize) -> String { fn format_line_number(line_number: usize, line_side: bool, line_count_width: usize) -> String {
if line_side { if line_side {
// Right side: left-align with trailing space for scrollbar clearance
format!("{:<width$}", line_number, width = line_count_width) format!("{:<width$}", line_number, width = line_count_width)
} else { } else {
// Left side: right-align, no trailing space (separator provides gap)
format!("{:>width$}", line_number, width = line_count_width) format!("{:>width$}", line_number, width = line_count_width)
} }
} }
@ -24,12 +22,12 @@ pub(super) fn calculate_visual_line_mapping(
continue; continue;
} }
let galley = ui.fonts_mut(|fonts| { let galley = ui.fonts(|fonts| {
fonts.layout( fonts.layout(
line.to_string(), line.to_string(),
font_id.to_owned(), font_id.to_owned(),
egui::Color32::WHITE, egui::Color32::WHITE,
available_width, available_width - font_id.size,
) )
}); });
@ -57,23 +55,12 @@ pub(super) fn render_line_numbers(
word_wrap: bool, word_wrap: bool,
line_side: bool, line_side: bool,
font_size: f32, font_size: f32,
monospace: bool,
) { ) {
ui.vertical(|ui| { ui.vertical(|ui| {
ui.disable(); ui.disable();
ui.set_width(line_number_width); ui.set_width(line_number_width);
ui.spacing_mut().item_spacing.y = 0.0;
let spacing_adjustment = if monospace { ui.add_space(2.0); // Text Editor default top margin
0.0
} else {
match font_size {
10.0 | 16.0 | 22.0 | 23.0 | 28.0 | 29.0 | 30.0 => -1.0,
_ => 0.0,
}
};
ui.spacing_mut().item_spacing.y = spacing_adjustment;
ui.add_space(if monospace { 1.0 } else { 2.0 }); // Text Editor default top margin
let text_color = ui.visuals().weak_text_color(); let text_color = ui.visuals().weak_text_color();
let bg_color = ui.visuals().extreme_bg_color; let bg_color = ui.visuals().extreme_bg_color;

View File

@ -1,14 +0,0 @@
use crate::app::TextEditor;
use eframe::egui;
use egui_commonmark::CommonMarkViewer;
pub(super) fn markdown_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) {
let content = if let Some(active_tab) = app.get_active_tab() {
active_tab.content.clone()
} else {
ui.label("No file open");
return;
};
CommonMarkViewer::new().show(ui, &mut app.markdown_cache, &content);
}

View File

@ -24,5 +24,3 @@ pub const DEFAULT_FONT_SIZE_STR: &str = "14";
pub const PREVIEW_AREA_MAX_HEIGHT: f32 = 150.0; pub const PREVIEW_AREA_MAX_HEIGHT: f32 = 150.0;
pub const INNER_MARGIN: i8 = 8; pub const INNER_MARGIN: i8 = 8;
pub const SCROLLBAR_WIDTH: f32 = 25.0;

View File

@ -1,932 +0,0 @@
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<PathBuf>,
selected_path: Option<PathBuf>,
renaming_path: Option<PathBuf>,
rename_text: String,
clipboard_paths: Vec<PathBuf>,
clipboard_operation: Option<ClipboardOperation>,
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<PathBuf>) {
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<PathBuf>, 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<PathBuf>, &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<String> {
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<usize> {
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<PathBuf>,
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<PathBuf> {
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<PathBuf> {
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
_ => "",
},
}
}

View File

@ -1,6 +1,5 @@
use crate::app::TextEditor; use crate::app::TextEditor;
use crate::ui::constants::*; use crate::ui::constants::*;
use crate::ui::focus_manager::{FocusTarget, priorities};
use eframe::egui; use eframe::egui;
pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) { pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) {
@ -60,7 +59,7 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) {
let response = ui.add( let response = ui.add(
egui::TextEdit::singleline(&mut app.find_query) egui::TextEdit::singleline(&mut app.find_query)
.desired_width(250.0) .desired_width(250.0)
.hint_text("Search..."), .hint_text("Enter search text..."),
); );
if response.changed() { if response.changed() {
@ -68,13 +67,13 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) {
} }
if just_opened || focus_requested || app.focus_find { if just_opened || focus_requested || app.focus_find {
app.focus_manager.request_focus(FocusTarget::FindInput, priorities::HIGH); response.request_focus();
app.focus_find = false; app.focus_find = false;
} }
if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
app.find_next(ctx); app.find_next(ctx);
app.focus_manager.request_focus(FocusTarget::FindInput, priorities::HIGH); response.request_focus();
} }
}); });
@ -85,7 +84,7 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) {
let _replace_response = ui.add( let _replace_response = ui.add(
egui::TextEdit::singleline(&mut app.replace_query) egui::TextEdit::singleline(&mut app.replace_query)
.desired_width(250.0) .desired_width(250.0)
.hint_text("Replace..."), .hint_text("Enter replacement text..."),
); );
}); });
} }

View File

@ -1,110 +0,0 @@
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<FocusTarget>,
/// 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<FocusTarget> {
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;
}

View File

@ -1,5 +1,4 @@
use crate::app::TextEditor; use crate::{app::TextEditor, io};
use crate::app::actions::ShortcutAction;
use eframe::egui::{self, Frame}; use eframe::egui::{self, Frame};
use egui::UiKind; use egui::UiKind;
@ -49,25 +48,25 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
ui.menu_button("File", |ui| { ui.menu_button("File", |ui| {
app.menu_interaction_active = true; app.menu_interaction_active = true;
if ui.button("New").clicked() { if ui.button("New").clicked() {
app.perform_action(ShortcutAction::NewFile); io::new_file(app);
ui.close_kind(UiKind::Menu); ui.close_kind(UiKind::Menu);
} }
if ui.button("Open...").clicked() { if ui.button("Open...").clicked() {
app.perform_action(ShortcutAction::OpenFile); io::open_file(app);
ui.close_kind(UiKind::Menu); ui.close_kind(UiKind::Menu);
} }
ui.separator(); ui.separator();
if ui.button("Save").clicked() { if ui.button("Save").clicked() {
app.perform_action(ShortcutAction::SaveFile); io::save_file(app);
ui.close_kind(UiKind::Menu); ui.close_kind(UiKind::Menu);
} }
if ui.button("Save As...").clicked() { if ui.button("Save As...").clicked() {
app.perform_action(ShortcutAction::SaveAsFile); io::save_as_file(app);
ui.close_kind(UiKind::Menu); ui.close_kind(UiKind::Menu);
} }
ui.separator(); ui.separator();
if ui.button("Preferences").clicked() { if ui.button("Preferences").clicked() {
app.perform_action(ShortcutAction::Preferences); app.show_preferences = true;
ui.close_kind(UiKind::Menu); ui.close_kind(UiKind::Menu);
} }
if ui.button("Exit").clicked() { if ui.button("Exit").clicked() {
@ -104,19 +103,12 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
ui.close_kind(UiKind::Menu); ui.close_kind(UiKind::Menu);
} }
if ui.button("Select All").clicked() { if ui.button("Select All").clicked() {
if let Some(active_tab) = app.get_active_tab() { let text_edit_id = egui::Id::new("main_text_editor");
let id_source = active_tab
.file_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "untitled".to_string());
let text_edit_id =
egui::Id::new("main_text_editor").with(&id_source);
if let Some(mut state) = if let Some(mut state) =
egui::TextEdit::load_state(ui.ctx(), text_edit_id) egui::TextEdit::load_state(ui.ctx(), text_edit_id)
{ {
if let Some(active_tab) = app.get_active_tab() { if let Some(active_tab) = app.get_active_tab() {
let text_len = active_tab.content.chars().count(); let text_len = active_tab.content.len();
let select_all_range = egui::text::CCursorRange::two( let select_all_range = egui::text::CCursorRange::two(
egui::text::CCursor::new(0), egui::text::CCursor::new(0),
egui::text::CCursor::new(text_len), egui::text::CCursor::new(text_len),
@ -125,44 +117,11 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); egui::TextEdit::store_state(ui.ctx(), text_edit_id, state);
} }
} }
}
ui.close_kind(UiKind::Menu); ui.close_kind(UiKind::Menu);
} }
ui.separator(); ui.separator();
// Check if undo is available if ui.button("Undo").clicked() {
let can_undo = if let Some(active_tab) = app.get_active_tab() { let text_edit_id = egui::Id::new("main_text_editor");
let id_source = active_tab
.file_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "untitled".to_string());
let text_edit_id = egui::Id::new("main_text_editor").with(&id_source);
if let Some(state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id)
{
let current_state = (
state.cursor.char_range().unwrap_or_default(),
active_tab.content.to_string(),
);
state.undoer().undo(&current_state).is_some()
} else {
false
}
} else {
false
};
if ui
.add_enabled(can_undo, egui::Button::new("Undo"))
.clicked()
{
if let Some(active_tab) = app.get_active_tab_mut() {
let id_source = active_tab
.file_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "untitled".to_string());
let text_edit_id =
egui::Id::new("main_text_editor").with(&id_source);
if let Some(mut state) = if let Some(mut state) =
egui::TextEdit::load_state(ui.ctx(), text_edit_id) egui::TextEdit::load_state(ui.ctx(), text_edit_id)
{ {
@ -178,11 +137,7 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
active_tab.content = content.to_string(); active_tab.content = content.to_string();
state.cursor.set_char_range(Some(*cursor_range)); state.cursor.set_char_range(Some(*cursor_range));
state.set_undoer(undoer); state.set_undoer(undoer);
egui::TextEdit::store_state( egui::TextEdit::store_state(ui.ctx(), text_edit_id, state);
ui.ctx(),
text_edit_id,
state,
);
active_tab.update_modified_state(); active_tab.update_modified_state();
if app.show_find && !app.find_query.is_empty() { if app.show_find && !app.find_query.is_empty() {
app.update_find_matches(); app.update_find_matches();
@ -190,43 +145,10 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
} }
} }
} }
}
ui.close_kind(UiKind::Menu); ui.close_kind(UiKind::Menu);
} }
// Check if redo is available if ui.button("Redo").clicked() {
let can_redo = if let Some(active_tab) = app.get_active_tab() { let text_edit_id = egui::Id::new("main_text_editor");
let id_source = active_tab
.file_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "untitled".to_string());
let text_edit_id = egui::Id::new("main_text_editor").with(&id_source);
if let Some(state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id)
{
let current_state = (
state.cursor.char_range().unwrap_or_default(),
active_tab.content.to_string(),
);
state.undoer().redo(&current_state).is_some()
} else {
false
}
} else {
false
};
if ui
.add_enabled(can_redo, egui::Button::new("Redo"))
.clicked()
{
if let Some(active_tab) = app.get_active_tab_mut() {
let id_source = active_tab
.file_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "untitled".to_string());
let text_edit_id =
egui::Id::new("main_text_editor").with(&id_source);
if let Some(mut state) = if let Some(mut state) =
egui::TextEdit::load_state(ui.ctx(), text_edit_id) egui::TextEdit::load_state(ui.ctx(), text_edit_id)
{ {
@ -242,11 +164,7 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
active_tab.content = content.to_string(); active_tab.content = content.to_string();
state.cursor.set_char_range(Some(*cursor_range)); state.cursor.set_char_range(Some(*cursor_range));
state.set_undoer(undoer); state.set_undoer(undoer);
egui::TextEdit::store_state( egui::TextEdit::store_state(ui.ctx(), text_edit_id, state);
ui.ctx(),
text_edit_id,
state,
);
active_tab.update_modified_state(); active_tab.update_modified_state();
if app.show_find && !app.find_query.is_empty() { if app.show_find && !app.find_query.is_empty() {
app.update_find_matches(); app.update_find_matches();
@ -254,23 +172,21 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
} }
} }
} }
}
ui.close_kind(UiKind::Menu); ui.close_kind(UiKind::Menu);
} }
}); });
ui.menu_button("View", |ui| { ui.menu_button("View", |ui| {
app.menu_interaction_active = true; app.menu_interaction_active = true;
if ui if ui
.checkbox(&mut app.syntax_highlighting, "Syntax Highlighting") .checkbox(&mut app.show_line_numbers, "Show Line Numbers")
.clicked() .clicked()
{ {
app.save_config(); app.save_config();
ui.close_kind(UiKind::Menu); ui.close_kind(UiKind::Menu);
} }
if ui if ui
.checkbox(&mut app.show_markdown, "Preview Markdown") .checkbox(&mut app.syntax_highlighting, "Syntax Highlighting")
.clicked() .clicked()
{ {
app.save_config(); app.save_config();
@ -280,62 +196,27 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
app.save_config(); app.save_config();
ui.close_kind(UiKind::Menu); ui.close_kind(UiKind::Menu);
} }
if ui.checkbox(&mut app.show_hidden_files, "Show Hidden Files").clicked() { if ui.checkbox(&mut app.hide_tab_bar, "Hide Tab Bar").clicked() {
app.save_config();
ui.close_kind(UiKind::Menu);
}
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(); app.save_config();
ui.close_kind(UiKind::Menu); ui.close_kind(UiKind::Menu);
} }
if ui if ui
.checkbox(&mut app.show_bottom_bar, "Bottom Bar") .checkbox(&mut app.auto_hide_toolbar, "Auto Hide Toolbar")
.clicked() .clicked()
{ {
app.save_config(); app.save_config();
ui.close_kind(UiKind::Menu); ui.close_kind(UiKind::Menu);
} }
if ui.checkbox(&mut app.show_terminal, "Terminal").clicked() {
app.save_config(); 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.close_kind(UiKind::Menu);
} }
if ui
.checkbox(&mut app.auto_hide_toolbar, "Hide Toolbar") ui.separator();
.clicked()
{
app.save_config();
ui.close_kind(UiKind::Menu);
}
});
ui.menu_button("Appearance", |ui| { ui.menu_button("Appearance", |ui| {
app.menu_interaction_active = true; app.menu_interaction_active = true;
@ -377,18 +258,14 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
ui.close_kind(UiKind::Menu); ui.close_kind(UiKind::Menu);
} }
ui.separator(); ui.separator();
if ui.radio_value(&mut app.line_side, false, "Left").clicked() {
if ui.button("Reset Zoom").clicked() { app.save_config();
app.zoom_factor = 1.0; ui.close_kind(UiKind::Menu);
ctx.set_zoom_factor(1.0); }
ui.close_kind(UiKind::Menu); if ui.radio_value(&mut app.line_side, true, "Right").clicked() {
}
if ui.button("Focus Mode").clicked() {
app.focus_mode = true;
app.save_config(); app.save_config();
ui.close_kind(UiKind::Menu); ui.close_kind(UiKind::Menu);
} }
}); });
}); });
@ -404,7 +281,7 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
} }
}); });
if !app.show_tab_bar { if app.hide_tab_bar {
let tab_title = if let Some(tab) = app.get_active_tab() { let tab_title = if let Some(tab) = app.get_active_tab() {
tab.get_display_title() tab.get_display_title()
} else { } else {
@ -412,10 +289,10 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
empty_tab.get_display_title() empty_tab.get_display_title()
}; };
let window_width = ctx.viewport_rect().width(); let window_width = ctx.screen_rect().width();
let font_id = ui.style().text_styles[&egui::TextStyle::Body].to_owned(); let font_id = ui.style().text_styles[&egui::TextStyle::Body].to_owned();
let text_galley = ui.fonts_mut(|fonts| { let text_galley = ui.fonts(|fonts| {
fonts.layout_job(egui::text::LayoutJob::simple_singleline( fonts.layout_job(egui::text::LayoutJob::simple_singleline(
tab_title, tab_title,
font_id, font_id,

View File

@ -1,11 +1,10 @@
use crate::app::TextEditor; use crate::app::TextEditor;
use crate::ui::constants::*; use crate::ui::constants::*;
use crate::ui::focus_manager::{FocusTarget, priorities};
use eframe::egui; use eframe::egui;
pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
let visuals = &ctx.style().visuals; let visuals = &ctx.style().visuals;
let screen_rect = ctx.viewport_rect(); let screen_rect = ctx.screen_rect();
let window_width = let window_width =
(screen_rect.width() * WINDOW_WIDTH_RATIO).clamp(WINDOW_MIN_WIDTH, WINDOW_MAX_WIDTH); (screen_rect.width() * WINDOW_WIDTH_RATIO).clamp(WINDOW_MIN_WIDTH, WINDOW_MAX_WIDTH);
let window_height = let window_height =
@ -30,6 +29,9 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
}) })
.show(ctx, |ui| { .show(ctx, |ui| {
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
ui.heading("Editor Settings");
ui.add_space(MEDIUM);
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.vertical(|ui| { ui.vertical(|ui| {
if ui if ui
@ -45,19 +47,28 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
} }
} }
ui.add_space(SMALL); ui.add_space(SMALL);
if ui.checkbox(&mut app.word_wrap, "Word Wrap").changed() { if ui
.checkbox(&mut app.show_line_numbers, "Show Line Numbers")
.changed()
{
app.save_config(); app.save_config();
} }
ui.add_space(SMALL); 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() { if ui
app.save_config(); .checkbox(&mut app.auto_hide_toolbar, "Auto Hide Toolbar")
} .on_hover_text(
ui.add_space(SMALL); "Hide the top bar until you move your mouse to the upper edge",
if ui.checkbox(&mut app.follow_git, "Git").on_hover_text("Respect .gitignore file").changed() { )
.changed()
{
app.save_config(); app.save_config();
} }
}); });
ui.vertical(|ui| { ui.vertical(|ui| {
if ui.checkbox(&mut app.word_wrap, "Word Wrap").changed() {
app.save_config();
}
ui.add_space(SMALL);
if ui if ui
.checkbox(&mut app.syntax_highlighting, "Syntax Highlighting") .checkbox(&mut app.syntax_highlighting, "Syntax Highlighting")
.changed() .changed()
@ -66,54 +77,10 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
} }
ui.add_space(SMALL); ui.add_space(SMALL);
if ui if ui
.checkbox(&mut app.auto_indent, "Auto Indent") .checkbox(&mut app.hide_tab_bar, "Hide Tab Bar")
.on_hover_text("Automatically indent new lines to match the previous line") .on_hover_text(
.changed() "Hide the tab bar and show tab title in menu bar instead",
{ )
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() .changed()
{ {
app.save_config(); app.save_config();
@ -121,14 +88,16 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
}); });
}); });
ui.add_space(MEDIUM);
ui.separator();
ui.add_space(SMALL); ui.add_space(SMALL);
ui.separator();
ui.add_space(LARGE);
ui.heading("Font Settings");
ui.add_space(MEDIUM);
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.vertical(|ui| { ui.vertical(|ui| {
ui.label("Font Family:"); ui.label("Font Family:");
ui.add_space(MEDIUM); ui.add_space(SMALL);
ui.label("Font Size:"); ui.label("Font Size:");
}); });
@ -180,10 +149,10 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
app.font_size_input = Some(font_size_text.to_owned()); app.font_size_input = Some(font_size_text.to_owned());
if response.clicked() { if response.clicked() {
app.focus_manager.request_focus(FocusTarget::FontSizeInput, priorities::NORMAL); response.request_focus();
} }
ui.label("pt"); ui.label("px");
if response.lost_focus() { if response.lost_focus() {
if let Ok(new_size) = font_size_text.parse::<f32>() { if let Ok(new_size) = font_size_text.parse::<f32>() {
@ -201,50 +170,6 @@ 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::<usize>() {
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); ui.add_space(MEDIUM);

View File

@ -1,288 +0,0 @@
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<PtyHandle>,
pub output_buffer: Arc<Mutex<String>>,
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();
}
}

View File

@ -3,163 +3,62 @@ use crate::ui::constants::*;
use eframe::egui; use eframe::egui;
fn render_shortcuts_content(ui: &mut egui::Ui) { fn render_shortcuts_content(ui: &mut egui::Ui) {
let description_color = ui.visuals().text_color().gamma_multiply(0.8);
ui.vertical(|ui| {
ui.add_space(MEDIUM);
// Navigation section
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
ui.label( ui.label(
egui::RichText::new("Navigation") egui::RichText::new("Navigation")
.size(UI_HEADER_SIZE) .size(UI_HEADER_SIZE)
.strong(), .strong(),
); );
}); ui.label(egui::RichText::new("Ctrl + N: New").size(UI_TEXT_SIZE));
ui.add_space(MEDIUM); 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.horizontal(|ui| { ui.label(egui::RichText::new("Ctrl + Shift + S: Save As").size(UI_TEXT_SIZE));
ui.add_space(MEDIUM); ui.label(egui::RichText::new("Ctrl + T: New Tab").size(UI_TEXT_SIZE));
ui.columns(2, |columns| { ui.label(egui::RichText::new("Ctrl + W: Close Tab").size(UI_TEXT_SIZE));
let shortcuts = [ ui.label(egui::RichText::new("Ctrl + Tab: Next Tab").size(UI_TEXT_SIZE));
("Ctrl + N", "New"), ui.label(egui::RichText::new("Ctrl + Shift + Tab: Last Tab").size(UI_TEXT_SIZE));
("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.add_space(VLARGE);
ui.separator(); ui.separator();
ui.add_space(MEDIUM);
// Editing section
ui.vertical_centered(|ui| {
ui.label(egui::RichText::new("Editing").size(UI_HEADER_SIZE).strong()); ui.label(egui::RichText::new("Editing").size(UI_HEADER_SIZE).strong());
}); ui.label(egui::RichText::new("Ctrl + Z: Undo").size(UI_TEXT_SIZE));
ui.add_space(MEDIUM); 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.horizontal(|ui| { ui.label(egui::RichText::new("Ctrl + C: Copy").size(UI_TEXT_SIZE));
ui.add_space(MEDIUM); ui.label(egui::RichText::new("Ctrl + V: Paste").size(UI_TEXT_SIZE));
ui.columns(2, |columns| { ui.label(egui::RichText::new("Ctrl + A: Select All").size(UI_TEXT_SIZE));
let shortcuts = [ ui.label(egui::RichText::new("Ctrl + D: Delete Line").size(UI_TEXT_SIZE));
("Ctrl + Z", "Undo"), ui.label(egui::RichText::new("Ctrl + F: Find").size(UI_TEXT_SIZE));
("Ctrl + Shift + Z", "Redo"), ui.label(egui::RichText::new("Ctrl + R: Replace").size(UI_TEXT_SIZE));
("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.add_space(VLARGE);
ui.separator(); ui.separator();
ui.add_space(MEDIUM);
// Views section
ui.vertical_centered(|ui| {
ui.label(egui::RichText::new("Views").size(UI_HEADER_SIZE).strong()); 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.add_space(MEDIUM); ui.label(
egui::RichText::new("Ctrl + Shift + L: Change Line Number Side").size(UI_TEXT_SIZE),
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( ui.label(egui::RichText::new("Ctrl + K: Toggle Word Wrap").size(UI_TEXT_SIZE));
egui::RichText::new(*description) ui.label(egui::RichText::new("Ctrl + H: Toggle Auto Hide Toolbar").size(UI_TEXT_SIZE));
.size(UI_TEXT_SIZE) ui.label(egui::RichText::new("Ctrl + P: Preferences").size(UI_TEXT_SIZE));
.color(description_color), 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(
// Add space after each complete row (every 2 items) // egui::RichText::new("Ctrl + Shift + .: Toggle Vim Mode")
if i % 2 == 1 { // .size(14.0)
columns[0].add_space(MEDIUM); // );
columns[1].add_space(MEDIUM); // ui.label(
} // egui::RichText::new("Ctrl + .: Toggle Vim Mode")
} // .size(14.0)
}); // );
});
ui.add_space(VLARGE); ui.add_space(VLARGE);
ui.separator();
}); });
} }
pub(crate) fn shortcuts_window(app: &mut TextEditor, ctx: &egui::Context) { pub(crate) fn shortcuts_window(app: &mut TextEditor, ctx: &egui::Context) {
let visuals = &ctx.style().visuals; let visuals = &ctx.style().visuals;
let screen_rect = ctx.viewport_rect(); let screen_rect = ctx.screen_rect();
let window_width = let window_width =
(screen_rect.width() * WINDOW_WIDTH_RATIO).clamp(WINDOW_MIN_WIDTH, WINDOW_MAX_WIDTH); (screen_rect.width() * WINDOW_WIDTH_RATIO).clamp(WINDOW_MIN_WIDTH, WINDOW_MAX_WIDTH);
@ -197,8 +96,7 @@ pub(crate) fn shortcuts_window(app: &mut TextEditor, ctx: &egui::Context) {
); );
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
//ui.add_space(MEDIUM); ui.add_space(MEDIUM);
ui.separator();
let visuals = ui.visuals(); let visuals = ui.visuals();
let close_button = egui::Button::new("Close") let close_button = egui::Button::new("Close")
.fill(visuals.widgets.inactive.bg_fill) .fill(visuals.widgets.inactive.bg_fill)

View File

@ -1,8 +0,0 @@
pub(crate) fn safe_slice_to_pos(content: &str, pos: usize) -> &str {
let pos = pos.min(content.len());
let mut boundary_pos = pos;
while boundary_pos > 0 && !content.is_char_boundary(boundary_pos) {
boundary_pos -= 1;
}
&content[..boundary_pos]
}