From 0444080284c92a7964c296e2d69a6a638091c24f Mon Sep 17 00:00:00 2001 From: candle Date: Sat, 5 Jul 2025 14:42:45 -0400 Subject: [PATCH] initial commit --- .gitignore | 4 + Cargo.toml | 13 ++ ced.desktop | 8 + src/app.rs | 7 + src/app/config.rs | 95 ++++++++ src/app/shortcuts.rs | 301 +++++++++++++++++++++++++ src/app/state.rs | 11 + src/app/state/app_impl.rs | 122 ++++++++++ src/app/state/config.rs | 99 ++++++++ src/app/state/default.rs | 52 +++++ src/app/state/editor.rs | 75 ++++++ src/app/state/find.rs | 75 ++++++ src/app/state/lifecycle.rs | 124 ++++++++++ src/app/state/processing.rs | 63 ++++++ src/app/state/tabs.rs | 35 +++ src/app/state/ui.rs | 162 +++++++++++++ src/app/tab.rs | 80 +++++++ src/app/theme.rs | 196 ++++++++++++++++ src/io.rs | 88 ++++++++ src/main.rs | 26 +++ src/ui.rs | 7 + src/ui/about_window.rs | 41 ++++ src/ui/central_panel.rs | 112 +++++++++ src/ui/central_panel/editor.rs | 222 ++++++++++++++++++ src/ui/central_panel/find_highlight.rs | 83 +++++++ src/ui/central_panel/line_numbers.rs | 119 ++++++++++ src/ui/find_window.rs | 121 ++++++++++ src/ui/menu_bar.rs | 271 ++++++++++++++++++++++ src/ui/preferences_window.rs | 149 ++++++++++++ src/ui/shortcuts_window.rs | 102 +++++++++ src/ui/tab_bar.rs | 88 ++++++++ 31 files changed, 2951 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 ced.desktop create mode 100644 src/app.rs create mode 100644 src/app/config.rs create mode 100644 src/app/shortcuts.rs create mode 100644 src/app/state.rs create mode 100644 src/app/state/app_impl.rs create mode 100644 src/app/state/config.rs create mode 100644 src/app/state/default.rs create mode 100644 src/app/state/editor.rs create mode 100644 src/app/state/find.rs create mode 100644 src/app/state/lifecycle.rs create mode 100644 src/app/state/processing.rs create mode 100644 src/app/state/tabs.rs create mode 100644 src/app/state/ui.rs create mode 100644 src/app/tab.rs create mode 100644 src/app/theme.rs create mode 100644 src/io.rs create mode 100644 src/main.rs create mode 100644 src/ui.rs create mode 100644 src/ui/about_window.rs create mode 100644 src/ui/central_panel.rs create mode 100644 src/ui/central_panel/editor.rs create mode 100644 src/ui/central_panel/find_highlight.rs create mode 100644 src/ui/central_panel/line_numbers.rs create mode 100644 src/ui/find_window.rs create mode 100644 src/ui/menu_bar.rs create mode 100644 src/ui/preferences_window.rs create mode 100644 src/ui/shortcuts_window.rs create mode 100644 src/ui/tab_bar.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6d65e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +Cargo.lock +/target +perf.* +flamegraph.* diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e48f1ed --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ced" +version = "0.0.3" +edition = "2024" + +[dependencies] +eframe = "0.31" +egui = "0.31" +serde = { version = "1.0", features = ["derive"] } +rfd = "0.15" +toml = "0.8" +dirs = "5.0" +libc = "0.2" diff --git a/ced.desktop b/ced.desktop new file mode 100644 index 0000000..26895e1 --- /dev/null +++ b/ced.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=Text Editor +Name[en_US]=Text Editor +Exec=/usr/bin/ced +Icon=editor +Terminal=false +Type=Application +Categories=Application;Graphical; diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..93aef0e --- /dev/null +++ b/src/app.rs @@ -0,0 +1,7 @@ +pub mod config; +pub mod shortcuts; +pub mod state; +pub mod tab; +pub mod theme; + +pub use state::TextEditor; diff --git a/src/app/config.rs b/src/app/config.rs new file mode 100644 index 0000000..7a21592 --- /dev/null +++ b/src/app/config.rs @@ -0,0 +1,95 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use super::theme::Theme; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub auto_hide_toolbar: bool, + pub show_line_numbers: bool, + pub word_wrap: bool, + pub theme: Theme, + pub line_side: bool, + pub font_family: String, + pub font_size: f32, + // pub vim_mode: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + auto_hide_toolbar: false, + show_line_numbers: false, + word_wrap: true, + theme: Theme::default(), + line_side: false, + font_family: "Proportional".to_string(), + font_size: 14.0, + // vim_mode: false, + } + } +} + +impl Config { + pub fn config_path() -> Option { + let config_dir = if let Some(config_dir) = dirs::config_dir() { + config_dir.join("ced") + } else if let Some(home_dir) = dirs::home_dir() { + home_dir.join(".config").join("ced") + } else { + return None; + }; + + Some(config_dir.join("config.toml")) + } + + pub fn load() -> Self { + let config_path = match Self::config_path() { + Some(path) => path, + None => return Self::default(), + }; + + if !config_path.exists() { + let default_config = Self::default(); + if let Err(e) = default_config.save() { + eprintln!("Failed to create default config file: {}", e); + } + return default_config; + } + + match std::fs::read_to_string(&config_path) { + Ok(content) => match toml::from_str::(&content) { + Ok(config) => config, + Err(e) => { + eprintln!( + "Failed to parse config file {}: {}", + config_path.display(), + e + ); + Self::default() + } + }, + Err(e) => { + eprintln!( + "Failed to read config file {}: {}", + config_path.display(), + e + ); + Self::default() + } + } + } + + pub fn save(&self) -> Result<(), Box> { + let config_path = Self::config_path().ok_or("Cannot determine config directory")?; + + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let content = toml::to_string_pretty(self)?; + std::fs::write(&config_path, content)?; + + Ok(()) + } +} diff --git a/src/app/shortcuts.rs b/src/app/shortcuts.rs new file mode 100644 index 0000000..7d65578 --- /dev/null +++ b/src/app/shortcuts.rs @@ -0,0 +1,301 @@ +use crate::app::state::TextEditor; +use crate::io; +use eframe::egui; + +#[derive(Debug, Clone, Copy)] +enum ShortcutAction { + NewFile, + OpenFile, + SaveFile, + SaveAsFile, + NewTab, + CloseTab, + ToggleLineNumbers, + ToggleLineSide, + ToggleWordWrap, + ToggleAutoHideToolbar, + ToggleFind, + NextTab, + PrevTab, + PageUp, + PageDown, + ZoomIn, + ZoomOut, + GlobalZoomIn, + GlobalZoomOut, + ResetZoom, + Escape, + Preferences, + ToggleVimMode, +} + +type ShortcutDefinition = (egui::Modifiers, egui::Key, ShortcutAction); + +fn get_shortcuts() -> Vec { + vec![ + (egui::Modifiers::CTRL, egui::Key::N, ShortcutAction::NewFile), + ( + egui::Modifiers::CTRL, + egui::Key::O, + ShortcutAction::OpenFile, + ), + ( + egui::Modifiers::CTRL | egui::Modifiers::SHIFT, + egui::Key::S, + ShortcutAction::SaveAsFile, + ), + ( + egui::Modifiers::CTRL, + egui::Key::S, + ShortcutAction::SaveFile, + ), + (egui::Modifiers::CTRL, egui::Key::T, ShortcutAction::NewTab), + ( + egui::Modifiers::CTRL, + egui::Key::W, + ShortcutAction::CloseTab, + ), + ( + egui::Modifiers::CTRL, + egui::Key::F, + ShortcutAction::ToggleFind, + ), + ( + egui::Modifiers::CTRL | egui::Modifiers::SHIFT, + egui::Key::L, + ShortcutAction::ToggleLineSide, + ), + ( + egui::Modifiers::CTRL, + egui::Key::L, + ShortcutAction::ToggleLineNumbers, + ), + ( + egui::Modifiers::CTRL, + egui::Key::K, + ShortcutAction::ToggleWordWrap, + ), + ( + egui::Modifiers::CTRL, + egui::Key::H, + ShortcutAction::ToggleAutoHideToolbar, + ), + ( + egui::Modifiers::CTRL | egui::Modifiers::SHIFT, + egui::Key::Tab, + ShortcutAction::PrevTab, + ), + ( + egui::Modifiers::CTRL, + egui::Key::Tab, + ShortcutAction::NextTab, + ), + ( + egui::Modifiers::NONE, + egui::Key::PageUp, + ShortcutAction::PageUp, + ), + ( + egui::Modifiers::NONE, + egui::Key::PageDown, + ShortcutAction::PageDown, + ), + ( + egui::Modifiers::CTRL, + egui::Key::Equals, + ShortcutAction::ZoomIn, + ), + ( + egui::Modifiers::CTRL | egui::Modifiers::SHIFT, + egui::Key::Minus, + ShortcutAction::GlobalZoomOut, + ), + ( + egui::Modifiers::CTRL, + egui::Key::Minus, + ShortcutAction::ZoomOut, + ), + ( + egui::Modifiers::CTRL, + egui::Key::Plus, + ShortcutAction::GlobalZoomIn, + ), + ( + egui::Modifiers::CTRL, + egui::Key::Num0, + ShortcutAction::ResetZoom, + ), + ( + egui::Modifiers::CTRL, + egui::Key::P, + ShortcutAction::Preferences, + ), + ( + egui::Modifiers::CTRL | egui::Modifiers::SHIFT, + egui::Key::Period, + ShortcutAction::ToggleVimMode, + ), + ( + egui::Modifiers::NONE, + egui::Key::Escape, + ShortcutAction::Escape, + ), + ] +} + +fn execute_action(action: ShortcutAction, editor: &mut TextEditor, ctx: &egui::Context) -> bool { + 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 { + // Check if the current tab has unsaved changes + if let Some(current_tab) = editor.get_active_tab() { + if current_tab.is_modified { + // Show dialog for unsaved changes + editor.pending_unsaved_action = Some(super::state::UnsavedAction::CloseTab(editor.active_tab_index)); + } else { + // Close tab directly if no unsaved changes + 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; + editor.show_find = false; + editor.show_preferences = false; + editor.pending_unsaved_action = None; + false + } + ShortcutAction::ToggleFind => { + //editor.show_find = !editor.show_find; + false + } + ShortcutAction::Preferences => { + editor.show_preferences = !editor.show_preferences; + false + } + } +} + +pub fn handle(editor: &mut TextEditor, ctx: &egui::Context) { + let mut font_zoom_occurred = false; + let mut global_zoom_occurred = false; + + ctx.input_mut(|i| { + for (modifiers, key, action) in get_shortcuts() { + if i.consume_key(modifiers, key) { + match action { + ShortcutAction::ZoomIn | ShortcutAction::ZoomOut => { + font_zoom_occurred = execute_action(action, editor, ctx); + } + ShortcutAction::GlobalZoomIn + | ShortcutAction::GlobalZoomOut + | ShortcutAction::ResetZoom => { + execute_action(action, editor, ctx); + global_zoom_occurred = true; + } + _ => { + execute_action(action, editor, ctx); + } + } + break; + } + } + }); + + if font_zoom_occurred { + editor.apply_font_settings(ctx); + } + + if global_zoom_occurred { + ctx.set_zoom_factor(editor.zoom_factor); + } +} diff --git a/src/app/state.rs b/src/app/state.rs new file mode 100644 index 0000000..184ac08 --- /dev/null +++ b/src/app/state.rs @@ -0,0 +1,11 @@ +mod app_impl; +mod config; +mod default; +mod editor; +mod find; +mod lifecycle; +mod processing; +mod tabs; +mod ui; + +pub use editor::{TextEditor, UnsavedAction}; diff --git a/src/app/state/app_impl.rs b/src/app/state/app_impl.rs new file mode 100644 index 0000000..20d9f55 --- /dev/null +++ b/src/app/state/app_impl.rs @@ -0,0 +1,122 @@ +use crate::app::shortcuts; +use crate::ui::{ + about_window::about_window, central_panel::central_panel, find_window::find_window, + menu_bar::menu_bar, preferences_window::preferences_window, shortcuts_window::shortcuts_window, + tab_bar::tab_bar, +}; +use eframe::egui; + +use super::editor::TextEditor; + +impl eframe::App for TextEditor { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + if ctx.input(|i| i.viewport().close_requested()) + && !self.force_quit_confirmed + && !self.clean_quit_requested + { + self.request_quit(ctx); + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } + + shortcuts::handle(self, ctx); + + ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.get_title())); + + menu_bar(self, ctx); + + // if self.tabs.len() > 1 { + tab_bar(self, ctx); + // } + + // Extract data needed for calculations to avoid borrow conflicts + let (content_changed, layout_changed, needs_processing) = if let Some(active_tab) = self.get_active_tab() { + let content_changed = active_tab.last_content_hash != crate::app::tab::compute_content_hash(&active_tab.content, &mut std::hash::DefaultHasher::new()); + let layout_changed = self.needs_width_calculation(ctx); + (content_changed, layout_changed, true) + } else { + (false, false, false) + }; + + if needs_processing { + // Only recalculate width when layout parameters change, not on every keystroke + if layout_changed { + let width = if self.word_wrap { + // For word wrap, width only depends on layout parameters + let total_width = ctx.available_rect().width(); + if self.show_line_numbers { + let line_count = if let Some(tab) = self.get_active_tab() { + tab.content.lines().count().max(1) + } else { + 1 + }; + let line_count_digits = line_count.to_string().len(); + let estimated_char_width = self.font_size * 0.6; + let base_line_number_width = line_count_digits as f32 * estimated_char_width; + let line_number_width = if self.line_side { + base_line_number_width + 20.0 + } else { + base_line_number_width + 8.0 + }; + (total_width - line_number_width - 10.0).max(100.0) + } else { + total_width + } + } else { + // For non-word wrap, use a generous fixed width to avoid constant recalculation + // This prevents cursor jumping while still allowing horizontal scrolling + let base_width = ctx.available_rect().width(); + if self.show_line_numbers { + let estimated_char_width = self.font_size * 0.6; + let line_number_width = if self.line_side { 60.0 } else { 40.0 }; + (base_width - line_number_width - 10.0).max(100.0) + } else { + base_width + } + }; + + self.update_width_calculation_state(ctx, width); + } + + // Process text changes using stable cached width + if content_changed { + if let Some(active_tab) = self.get_active_tab() { + let content = active_tab.content.clone(); + let word_wrap = self.word_wrap; + let cached_width = self.get_cached_width(); + let available_width = cached_width.unwrap_or_else(|| { + // Initialize with a reasonable default if no cache exists + if word_wrap { + ctx.available_rect().width() + } else { + ctx.available_rect().width() + } + }); + + self.process_text_for_rendering(&content, available_width); + } + } + } + + central_panel(self, ctx); + + if self.show_about { + about_window(self, ctx); + } + if self.show_shortcuts { + shortcuts_window(self, ctx); + } + if self.show_preferences { + preferences_window(self, ctx); + } + if self.show_find { + find_window(self, ctx); + } + if self.pending_unsaved_action.is_some() { + self.show_unsaved_changes_dialog(ctx); + } + + // Update the previous find state for next frame + self.prev_show_find = self.show_find; + + } +} diff --git a/src/app/state/config.rs b/src/app/state/config.rs new file mode 100644 index 0000000..5e548c0 --- /dev/null +++ b/src/app/state/config.rs @@ -0,0 +1,99 @@ +use super::editor::TextEditor; +use crate::app::config::Config; +use crate::app::tab::Tab; +use crate::app::theme; + +impl TextEditor { + pub fn from_config(config: Config) -> Self { + Self { + tabs: vec![Tab::new_empty(1)], + active_tab_index: 0, + tab_counter: 1, + show_about: false, + show_shortcuts: false, + show_find: false, + show_preferences: false, + pending_unsaved_action: None, + force_quit_confirmed: false, + clean_quit_requested: false, + show_line_numbers: config.show_line_numbers, + word_wrap: config.word_wrap, + auto_hide_toolbar: config.auto_hide_toolbar, + theme: config.theme, + line_side: config.line_side, + font_family: config.font_family, + font_size: config.font_size, + font_size_input: None, + zoom_factor: 1.0, + menu_interaction_active: false, + tab_bar_rect: None, + menu_bar_stable_until: None, + text_processing_result: std::sync::Arc::new(std::sync::Mutex::new(Default::default())), + processing_thread_handle: None, + find_query: String::new(), + find_matches: Vec::new(), + current_match_index: None, + case_sensitive_search: false, + prev_show_find: false, + // Width calculation cache and state tracking + cached_width: None, + last_word_wrap: config.word_wrap, + last_show_line_numbers: config.show_line_numbers, + last_font_size: config.font_size, + last_line_side: config.line_side, + last_viewport_width: 0.0, + // vim_mode: config.vim_mode, + + // Cursor tracking for smart scrolling + previous_cursor_position: None, + } + } + + pub fn from_config_with_context(config: Config, cc: &eframe::CreationContext<'_>) -> Self { + let mut editor = Self::from_config(config); + theme::apply(editor.theme, &cc.egui_ctx); + + cc.egui_ctx.options_mut(|o| o.zoom_with_keyboard = false); + + let mut style = (*cc.egui_ctx.style()).clone(); + style + .text_styles + .insert(egui::TextStyle::Body, egui::FontId::proportional(16.0)); + style + .text_styles + .insert(egui::TextStyle::Button, egui::FontId::proportional(16.0)); + style + .text_styles + .insert(egui::TextStyle::Heading, egui::FontId::proportional(22.0)); + style + .text_styles + .insert(egui::TextStyle::Small, egui::FontId::proportional(14.0)); + cc.egui_ctx.set_style(style); + + editor.apply_font_settings(&cc.egui_ctx); + + editor.start_text_processing_thread(); + + editor + } + + pub fn get_config(&self) -> Config { + Config { + auto_hide_toolbar: self.auto_hide_toolbar, + show_line_numbers: self.show_line_numbers, + word_wrap: self.word_wrap, + theme: self.theme, + line_side: self.line_side, + font_family: self.font_family.clone(), + font_size: self.font_size, + // vim_mode: self.vim_mode, + } + } + + pub fn save_config(&self) { + let config = self.get_config(); + if let Err(e) = config.save() { + eprintln!("Failed to save configuration: {}", e); + } + } +} diff --git a/src/app/state/default.rs b/src/app/state/default.rs new file mode 100644 index 0000000..9708f37 --- /dev/null +++ b/src/app/state/default.rs @@ -0,0 +1,52 @@ +use super::editor::TextEditor; +use super::editor::TextProcessingResult; +use crate::app::{tab::Tab, theme::Theme}; +use std::sync::{Arc, Mutex}; + +impl Default for TextEditor { + fn default() -> Self { + Self { + tabs: vec![Tab::new_empty(1)], + active_tab_index: 0, + tab_counter: 1, + show_about: false, + show_shortcuts: false, + show_find: false, + show_preferences: false, + pending_unsaved_action: None, + force_quit_confirmed: false, + clean_quit_requested: false, + show_line_numbers: false, + word_wrap: true, + auto_hide_toolbar: false, + theme: Theme::default(), + line_side: false, + font_family: "Proportional".to_string(), + font_size: 14.0, + font_size_input: None, + zoom_factor: 1.0, + menu_interaction_active: false, + tab_bar_rect: None, + menu_bar_stable_until: None, + text_processing_result: Arc::new(Mutex::new(TextProcessingResult::default())), + processing_thread_handle: None, + // Find functionality + find_query: String::new(), + find_matches: Vec::new(), + current_match_index: None, + case_sensitive_search: false, + prev_show_find: false, + // Width calculation cache and state tracking + cached_width: None, + last_word_wrap: true, + last_show_line_numbers: false, + last_font_size: 14.0, + last_line_side: false, + last_viewport_width: 0.0, + // vim_mode: false, + + // Cursor tracking for smart scrolling + previous_cursor_position: None, + } + } +} diff --git a/src/app/state/editor.rs b/src/app/state/editor.rs new file mode 100644 index 0000000..acba608 --- /dev/null +++ b/src/app/state/editor.rs @@ -0,0 +1,75 @@ +use crate::app::tab::Tab; +use crate::app::theme::Theme; +use eframe::egui; +use std::sync::{Arc, Mutex}; +use std::thread; + +#[derive(Clone, PartialEq)] +pub enum UnsavedAction { + Quit, + CloseTab(usize), +} + +#[derive(Clone)] +pub struct TextProcessingResult { + pub line_count: usize, + pub visual_line_mapping: Vec>, + pub max_line_length: f32, + pub _processed_content: String, +} + +impl Default for TextProcessingResult { + fn default() -> Self { + Self { + line_count: 1, + visual_line_mapping: vec![Some(1)], + max_line_length: 0.0, + _processed_content: String::new(), + } + } +} + +#[derive()] +pub struct TextEditor { + pub(crate) tabs: Vec, + pub(crate) active_tab_index: usize, + pub(crate) tab_counter: usize, // Counter for numbering new tabs + pub(crate) show_about: bool, + pub(crate) show_shortcuts: bool, + pub(crate) show_find: bool, + pub(crate) show_preferences: bool, + pub(crate) pending_unsaved_action: Option, + pub(crate) force_quit_confirmed: bool, + pub(crate) clean_quit_requested: bool, + pub(crate) show_line_numbers: bool, + pub(crate) word_wrap: bool, + pub(crate) auto_hide_toolbar: bool, + pub(crate) theme: Theme, + pub(crate) line_side: bool, + pub(crate) font_family: String, + pub(crate) font_size: f32, + pub(crate) font_size_input: Option, + pub(crate) zoom_factor: f32, + pub(crate) menu_interaction_active: bool, + pub(crate) tab_bar_rect: Option, + pub(crate) menu_bar_stable_until: Option, + pub(crate) text_processing_result: Arc>, + pub(crate) processing_thread_handle: Option>, + pub(crate) find_query: String, + pub(crate) find_matches: Vec<(usize, usize)>, // (start_pos, end_pos) byte positions + pub(crate) current_match_index: Option, + pub(crate) case_sensitive_search: bool, + pub(crate) prev_show_find: bool, // Track previous state to detect transitions + + // Width calculation cache and state tracking + pub(crate) cached_width: Option, + pub(crate) last_word_wrap: bool, + pub(crate) last_show_line_numbers: bool, + pub(crate) last_font_size: f32, + pub(crate) last_line_side: bool, + pub(crate) last_viewport_width: f32, + // pub(crate) vim_mode: bool, + + // Cursor tracking for smart scrolling + pub(crate) previous_cursor_position: Option, +} diff --git a/src/app/state/find.rs b/src/app/state/find.rs new file mode 100644 index 0000000..cd80b23 --- /dev/null +++ b/src/app/state/find.rs @@ -0,0 +1,75 @@ +use super::editor::TextEditor; + +impl TextEditor { + pub fn update_find_matches(&mut self) { + self.find_matches.clear(); + self.current_match_index = None; + + if self.find_query.is_empty() { + return; + } + + if let Some(tab) = self.get_active_tab() { + let content = &tab.content; + let query = if self.case_sensitive_search { + self.find_query.clone() + } else { + self.find_query.to_lowercase() + }; + + let search_content = if self.case_sensitive_search { + content.clone() + } else { + content.to_lowercase() + }; + + let mut start = 0; + while let Some(pos) = search_content[start..].find(&query) { + let absolute_pos = start + pos; + self.find_matches + .push((absolute_pos, absolute_pos + query.len())); + start = absolute_pos + 1; + } + + if !self.find_matches.is_empty() { + self.current_match_index = Some(0); + } + } + } + + pub fn find_next(&mut self) { + if self.find_matches.is_empty() { + return; + } + + if let Some(current) = self.current_match_index { + self.current_match_index = Some((current + 1) % self.find_matches.len()); + } else { + self.current_match_index = Some(0); + } + } + + pub fn find_previous(&mut self) { + if self.find_matches.is_empty() { + return; + } + + if let Some(current) = self.current_match_index { + self.current_match_index = Some(if current == 0 { + self.find_matches.len() - 1 + } else { + current - 1 + }); + } else { + self.current_match_index = Some(0); + } + } + + pub fn get_current_match_position(&self) -> Option<(usize, usize)> { + if let Some(index) = self.current_match_index { + self.find_matches.get(index).copied() + } else { + None + } + } +} diff --git a/src/app/state/lifecycle.rs b/src/app/state/lifecycle.rs new file mode 100644 index 0000000..17da8a2 --- /dev/null +++ b/src/app/state/lifecycle.rs @@ -0,0 +1,124 @@ +use super::editor::{TextEditor, UnsavedAction}; +use eframe::egui; + +impl TextEditor { + pub fn has_unsaved_changes(&self) -> bool { + self.tabs.iter().any(|tab| tab.is_modified) + } + + pub fn get_unsaved_files(&self) -> Vec { + self.tabs + .iter() + .filter(|tab| tab.is_modified) + .map(|tab| tab.title.clone()) + .collect() + } + + pub fn request_quit(&mut self, ctx: &egui::Context) { + if self.has_unsaved_changes() { + self.pending_unsaved_action = Some(UnsavedAction::Quit); + } else { + self.clean_quit_requested = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + + pub fn force_quit(&mut self, ctx: &egui::Context) { + self.force_quit_confirmed = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + + pub(crate) fn show_unsaved_changes_dialog(&mut self, ctx: &egui::Context) { + let mut close_action_now = None; + let mut cancel_action = false; + + let (files_to_list, title, confirmation_text, button_text, action) = + if let Some(action) = &self.pending_unsaved_action { + match action { + UnsavedAction::Quit => ( + self.get_unsaved_files(), + "Unsaved Changes".to_string(), + "You have unsaved changes.".to_string(), + "Quit Without Saving".to_string(), + action.clone(), + ), + UnsavedAction::CloseTab(tab_index) => { + let file_name = self + .tabs + .get(*tab_index) + .map_or_else(|| "unknown file".to_string(), |tab| tab.title.clone()); + ( + vec![file_name], + "Unsaved Changes".to_string(), + "The file has unsaved changes.".to_string(), + "Close Without Saving".to_string(), + action.clone(), + ) + } + } + } else { + return; // Should not happen if called correctly + }; + + let visuals = &ctx.style().visuals; + egui::Window::new(title) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .frame(egui::Frame { + fill: visuals.window_fill, + stroke: visuals.window_stroke, + corner_radius: egui::CornerRadius::same(8), + shadow: visuals.window_shadow, + inner_margin: egui::Margin::same(16), + outer_margin: egui::Margin::same(0), + }) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.label(egui::RichText::new(&confirmation_text).size(14.0)); + ui.add_space(8.0); + + for file in &files_to_list { + ui.label(egui::RichText::new(format!("• {}", file)).size(18.0).weak()); + } + + ui.add_space(12.0); + ui.horizontal(|ui| { + let cancel_fill = ui.visuals().widgets.inactive.bg_fill; + let cancel_stroke = ui.visuals().widgets.inactive.bg_stroke; + let cancel_button = egui::Button::new("Cancel") + .fill(cancel_fill) + .stroke(cancel_stroke); + + if ui.add(cancel_button).clicked() { + cancel_action = true; + } + ui.add_space(8.0); + + let destructive_color = ui.visuals().error_fg_color; + let confirm_button = egui::Button::new(&button_text) + .fill(destructive_color) + .stroke(egui::Stroke::new(1.0, destructive_color)); + + if ui.add(confirm_button).clicked() { + close_action_now = Some(action); + } + }); + }); + }); + + if cancel_action { + self.pending_unsaved_action = None; + } + + if let Some(action) = close_action_now { + match action { + UnsavedAction::Quit => self.force_quit(ctx), + UnsavedAction::CloseTab(tab_index) => { + self.close_tab(tab_index); + } + } + self.pending_unsaved_action = None; + } + } +} diff --git a/src/app/state/processing.rs b/src/app/state/processing.rs new file mode 100644 index 0000000..5aea877 --- /dev/null +++ b/src/app/state/processing.rs @@ -0,0 +1,63 @@ +use std::sync::Arc; +use std::thread; + +use super::editor::{TextEditor, TextProcessingResult}; + +impl TextEditor { + pub fn start_text_processing_thread(&mut self) { + let _processing_result = Arc::clone(&self.text_processing_result); + + let handle = thread::Builder::new() + .name("TextProcessor".to_string()) + .spawn(move || { + // Set thread priority to high (platform-specific) + #[cfg(target_os = "linux")] + { + unsafe { + let thread_id = libc::pthread_self(); + let mut param: libc::sched_param = std::mem::zeroed(); + param.sched_priority = 50; + let _ = libc::pthread_setschedparam(thread_id, libc::SCHED_OTHER, ¶m); + } + } + }); + + match handle { + Ok(h) => self.processing_thread_handle = Some(h), + Err(e) => eprintln!("Failed to start text processing thread: {}", e), + } + } + + pub fn process_text_for_rendering( + &mut self, + content: &str, + available_width: f32, + ) { + let line_count = content.lines().count().max(1); + + let visual_line_mapping = if self.word_wrap { + // For now, simplified mapping - this could be moved to background thread + (1..=line_count).map(Some).collect() + } else { + (1..=line_count).map(Some).collect() + }; + + let result = TextProcessingResult { + line_count, + visual_line_mapping, + max_line_length: available_width, + _processed_content: content.to_string(), + }; + + if let Ok(mut processing_result) = self.text_processing_result.lock() { + *processing_result = result; + } + } + + pub fn get_text_processing_result(&self) -> TextProcessingResult { + self.text_processing_result + .lock() + .map(|result| result.clone()) + .unwrap_or_default() + } +} diff --git a/src/app/state/tabs.rs b/src/app/state/tabs.rs new file mode 100644 index 0000000..8e1ee75 --- /dev/null +++ b/src/app/state/tabs.rs @@ -0,0 +1,35 @@ +use super::editor::TextEditor; +use crate::app::tab::Tab; + +impl TextEditor { + pub fn get_active_tab(&self) -> Option<&Tab> { + self.tabs.get(self.active_tab_index) + } + + pub fn get_active_tab_mut(&mut self) -> Option<&mut Tab> { + self.tabs.get_mut(self.active_tab_index) + } + + pub fn add_new_tab(&mut self) { + self.tab_counter += 1; + self.tabs.push(Tab::new_empty(self.tab_counter)); + self.active_tab_index = self.tabs.len() - 1; + } + + pub fn close_tab(&mut self, tab_index: usize) { + if self.tabs.len() > 1 && tab_index < self.tabs.len() { + self.tabs.remove(tab_index); + if self.active_tab_index >= self.tabs.len() { + self.active_tab_index = self.tabs.len() - 1; + } else if self.active_tab_index > tab_index { + self.active_tab_index -= 1; + } + } + } + + pub fn switch_to_tab(&mut self, tab_index: usize) { + if tab_index < self.tabs.len() { + self.active_tab_index = tab_index; + } + } +} diff --git a/src/app/state/ui.rs b/src/app/state/ui.rs new file mode 100644 index 0000000..7d7a5af --- /dev/null +++ b/src/app/state/ui.rs @@ -0,0 +1,162 @@ +use super::editor::TextEditor; +use crate::app::theme; +use eframe::egui; + +pub struct EditorDimensions { + pub text_width: f32, + pub line_number_width: f32, + pub total_reserved_width: f32, +} + +impl TextEditor { + pub fn get_title(&self) -> String { + if let Some(tab) = self.get_active_tab() { + let modified_indicator = if tab.is_modified { "*" } else { "" }; + format!("{}{} - C-Text", tab.title, modified_indicator) + } else { + "C-Text".to_string() + } + } + + /// Get the configured font ID based on the editor's font settings + fn get_font_id(&self) -> egui::FontId { + let font_family = match self.font_family.as_str() { + "Monospace" => egui::FontFamily::Monospace, + _ => egui::FontFamily::Proportional, + }; + egui::FontId::new(self.font_size, font_family) + } + + pub fn set_theme(&mut self, ctx: &egui::Context) { + theme::apply(self.theme, ctx); + self.save_config(); + } + + pub fn apply_font_settings(&mut self, ctx: &egui::Context) { + let font_family = match self.font_family.as_str() { + "Monospace" => egui::FontFamily::Monospace, + _ => egui::FontFamily::Proportional, + }; + + let mut style = (*ctx.style()).clone(); + style.text_styles.insert( + egui::TextStyle::Monospace, + egui::FontId::new(self.font_size, font_family), + ); + + ctx.set_style(style); + self.save_config(); + } + + /// Calculates the available width for the text editor, accounting for line numbers and separator + pub fn calculate_editor_dimensions(&self, ui: &egui::Ui) -> EditorDimensions { + let total_available_width = ui.available_width(); + + if !self.show_line_numbers { + return EditorDimensions { + text_width: total_available_width, + line_number_width: 0.0, + total_reserved_width: 0.0, + }; + } + + // Get line count from processing result + let processing_result = self.get_text_processing_result(); + let line_count = processing_result.line_count; + + // Calculate base line number width + let font_id = self.get_font_id(); + let line_count_digits = line_count.to_string().len(); + let sample_text = "9".repeat(line_count_digits); + let base_line_number_width = ui.fonts(|fonts| { + fonts + .layout(sample_text, font_id, egui::Color32::WHITE, f32::INFINITY) + .size() + .x + }); + + // Add padding based on line_side setting + let line_number_width = if self.line_side { + base_line_number_width + 20.0 // Extra padding when line numbers are on the side + } else { + base_line_number_width + 8.0 // Minimal padding when line numbers are normal + }; + + // Separator space (7.0 for separator + 3.0 spacing = 10.0 total) + let separator_width = 10.0; + + let total_reserved_width = line_number_width + separator_width; + let text_width = (total_available_width - total_reserved_width).max(100.0); // Minimum 100px for text + + EditorDimensions { + text_width, + line_number_width, + total_reserved_width, + } + } + + /// Calculate the available width for non-word-wrapped content based on content analysis + pub fn calculate_content_based_width(&self, ui: &egui::Ui) -> f32 { + if let Some(active_tab) = self.get_active_tab() { + let content = &active_tab.content; + + if content.is_empty() { + return self.calculate_editor_dimensions(ui).text_width; + } + + // Find the longest line + let longest_line = content + .lines() + .max_by_key(|line| line.chars().count()) + .unwrap_or(""); + + if longest_line.is_empty() { + return self.calculate_editor_dimensions(ui).text_width; + } + + // Calculate the width needed for the longest line + let font_id = self.get_font_id(); + let longest_line_width = ui.fonts(|fonts| { + fonts.layout( + longest_line.to_string(), + font_id, + egui::Color32::WHITE, + f32::INFINITY, + ).size().x + }) + 20.0; // Add some padding + + // Return the larger of the calculated width or minimum available width + let dimensions = self.calculate_editor_dimensions(ui); + longest_line_width.max(dimensions.text_width) + } else { + self.calculate_editor_dimensions(ui).text_width + } + } + + /// Check if width calculation needs to be performed based on parameter changes + pub fn needs_width_calculation(&self, ctx: &egui::Context) -> bool { + let current_viewport_width = ctx.available_rect().width(); + + self.cached_width.is_none() || + self.word_wrap != self.last_word_wrap || + self.show_line_numbers != self.last_show_line_numbers || + (self.font_size - self.last_font_size).abs() > 0.1 || + self.line_side != self.last_line_side || + (current_viewport_width - self.last_viewport_width).abs() > 1.0 + } + + /// Update the cached width calculation state + pub fn update_width_calculation_state(&mut self, ctx: &egui::Context, width: f32) { + self.cached_width = Some(width); + self.last_word_wrap = self.word_wrap; + self.last_show_line_numbers = self.show_line_numbers; + self.last_font_size = self.font_size; + self.last_line_side = self.line_side; + self.last_viewport_width = ctx.available_rect().width(); + } + + /// Get cached width if available, otherwise return None to indicate calculation is needed + pub fn get_cached_width(&self) -> Option { + self.cached_width + } +} diff --git a/src/app/tab.rs b/src/app/tab.rs new file mode 100644 index 0000000..6f1a56c --- /dev/null +++ b/src/app/tab.rs @@ -0,0 +1,80 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::path::PathBuf; + +pub fn compute_content_hash(content: &str, hasher: &mut DefaultHasher) -> u64 { + content.hash(hasher); + hasher.finish() +} + +#[derive(Clone)] +pub struct Tab { + pub content: String, + pub original_content_hash: u64, + pub last_content_hash: u64, + pub file_path: Option, + pub is_modified: bool, + pub title: String, + hasher: DefaultHasher, +} + +impl Tab { + pub fn new_empty(tab_number: usize) -> Self { + let content = String::new(); + let mut hasher = DefaultHasher::new(); + let hash = compute_content_hash(&content, &mut hasher); + Self { + original_content_hash: hash, + last_content_hash: hash, + content, + file_path: None, + is_modified: false, + title: format!("new_{}", tab_number), + hasher, + } + } + + pub fn new_with_file(content: String, file_path: PathBuf) -> Self { + let title = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Untitled") + .to_string(); + + let mut hasher = DefaultHasher::new(); + let hash = compute_content_hash(&content, &mut hasher); + Self { + original_content_hash: hash, + last_content_hash: hash, + content, + file_path: Some(file_path), + is_modified: false, + title, + hasher, + } + } + + pub fn get_display_title(&self) -> String { + let modified_indicator = if self.is_modified { "*" } else { "" }; + format!("{}{}", self.title, modified_indicator) + } + + pub fn update_modified_state(&mut self) { + // Compare current content hash with original content hash to determine if modified + // Special case: new_X tabs are only considered modified if they have content + if self.title.starts_with("new_") { + self.is_modified = !self.content.is_empty(); + } else { + let current_hash = compute_content_hash(&self.content, &mut self.hasher); + self.is_modified = current_hash != self.last_content_hash; + self.last_content_hash = current_hash; + } + } + + pub fn mark_as_saved(&mut self) { + // Update the original content hash to match current content after saving + self.original_content_hash = compute_content_hash(&self.content, &mut self.hasher); + self.last_content_hash = self.original_content_hash; + self.is_modified = false; + } +} diff --git a/src/app/theme.rs b/src/app/theme.rs new file mode 100644 index 0000000..8f9d083 --- /dev/null +++ b/src/app/theme.rs @@ -0,0 +1,196 @@ +use eframe::egui; + +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize, Default)] +pub enum Theme { + #[default] + System, + Light, + Dark, +} + +pub fn apply(theme: Theme, ctx: &egui::Context) { + match theme { + Theme::System => { + if let Some(system_visuals) = get_system_colors() { + ctx.set_visuals(system_visuals); + } else { + let is_dark = detect_system_dark_mode(); + if is_dark { + ctx.set_visuals(egui::Visuals::dark()); + } else { + ctx.set_visuals(egui::Visuals::light()); + } + } + } + Theme::Light => { + ctx.set_visuals(egui::Visuals::light()); + } + Theme::Dark => { + ctx.set_visuals(egui::Visuals::dark()); + } + } +} + +fn get_system_colors() -> Option { + if let Some(visuals) = get_pywal_colors() { + return Some(visuals); + } + + #[cfg(target_os = "linux")] + { + if let Some(visuals) = get_gtk_colors() { + return Some(visuals); + } + } + None +} + +fn get_pywal_colors() -> Option { + use std::fs; + use std::path::Path; + + let home = std::env::var("HOME").ok()?; + let colors_path = Path::new(&home).join(".cache/wal/colors"); + + if !colors_path.exists() { + return None; + } + + let colors_content = fs::read_to_string(&colors_path).ok()?; + let colors: Vec<&str> = colors_content.lines().collect(); + + if colors.len() < 8 { + return None; + } + + let parse_color = |hex: &str| -> Option { + if hex.len() != 7 || !hex.starts_with('#') { + return None; + } + let r = u8::from_str_radix(&hex[1..3], 16).ok()?; + let g = u8::from_str_radix(&hex[3..5], 16).ok()?; + let b = u8::from_str_radix(&hex[5..7], 16).ok()?; + Some(egui::Color32::from_rgb(r, g, b)) + }; + + let bg = parse_color(colors[0])?; + let fg = parse_color(colors.get(7).unwrap_or(&colors[0]))?; + let bg_alt = parse_color(colors.get(8).unwrap_or(&colors[0]))?; + let accent = parse_color(colors.get(1).unwrap_or(&colors[0]))?; + let secondary = parse_color(colors.get(2).unwrap_or(&colors[0]))?; + + let mut visuals = if is_dark_color(bg) { + egui::Visuals::dark() + } else { + egui::Visuals::light() + }; + + visuals.window_fill = bg; + visuals.extreme_bg_color = bg; + visuals.code_bg_color = bg; + visuals.panel_fill = bg; + + visuals.faint_bg_color = blend_colors(bg, bg_alt, 0.15); + visuals.error_fg_color = parse_color(colors.get(1).unwrap_or(&colors[0]))?; + + visuals.override_text_color = Some(fg); + + visuals.hyperlink_color = accent; + visuals.selection.bg_fill = blend_colors(accent, bg, 0.3); + visuals.selection.stroke.color = accent; + + let separator_color = blend_colors(fg, bg, 0.3); + + visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, separator_color); + visuals.widgets.noninteractive.bg_fill = bg; + visuals.widgets.noninteractive.fg_stroke.color = fg; + + visuals.widgets.inactive.bg_fill = blend_colors(bg, accent, 0.2); + visuals.widgets.inactive.fg_stroke.color = fg; + visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, blend_colors(accent, bg, 0.4)); + visuals.widgets.inactive.weak_bg_fill = blend_colors(bg, accent, 0.1); + + visuals.widgets.hovered.bg_fill = blend_colors(bg, accent, 0.3); + visuals.widgets.hovered.fg_stroke.color = fg; + visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, accent); + visuals.widgets.hovered.weak_bg_fill = blend_colors(bg, accent, 0.15); + + visuals.widgets.active.bg_fill = blend_colors(bg, accent, 0.4); + visuals.widgets.active.fg_stroke.color = fg; + visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, accent); + visuals.widgets.active.weak_bg_fill = blend_colors(bg, accent, 0.2); + + visuals.window_stroke = egui::Stroke::new(1.0, separator_color); + + visuals.widgets.open.bg_fill = blend_colors(bg, accent, 0.25); + visuals.widgets.open.fg_stroke.color = fg; + visuals.widgets.open.bg_stroke = egui::Stroke::new(1.0, accent); + visuals.widgets.open.weak_bg_fill = blend_colors(bg, accent, 0.15); + + visuals.striped = true; + + visuals.button_frame = true; + visuals.collapsing_header_frame = false; + + Some(visuals) +} + +fn get_gtk_colors() -> Option { + None +} + +fn is_dark_color(color: egui::Color32) -> bool { + let r = color.r() as f32 / 255.0; + let g = color.g() as f32 / 255.0; + let b = color.b() as f32 / 255.0; + + let r_lin = if r <= 0.04045 { + r / 12.92 + } else { + ((r + 0.055) / 1.055).powf(2.4) + }; + let g_lin = if g <= 0.04045 { + g / 12.92 + } else { + ((g + 0.055) / 1.055).powf(2.4) + }; + let b_lin = if b <= 0.04045 { + b / 12.92 + } else { + ((b + 0.055) / 1.055).powf(2.4) + }; + + let luminance = 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin; + + luminance < 0.5 +} + +fn blend_colors(base: egui::Color32, blend: egui::Color32, factor: f32) -> egui::Color32 { + let factor = factor.clamp(0.0, 1.0); + let inv_factor = 1.0 - factor; + + egui::Color32::from_rgb( + (base.r() as f32 * inv_factor + blend.r() as f32 * factor) as u8, + (base.g() as f32 * inv_factor + blend.g() as f32 * factor) as u8, + (base.b() as f32 * inv_factor + blend.b() as f32 * factor) as u8, + ) +} + +fn detect_system_dark_mode() -> bool { + #[cfg(target_os = "windows")] + { + true + } + #[cfg(target_os = "macos")] + { + true + } + #[cfg(target_os = "linux")] + { + true + } + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + true + } +} diff --git a/src/io.rs b/src/io.rs new file mode 100644 index 0000000..81e8927 --- /dev/null +++ b/src/io.rs @@ -0,0 +1,88 @@ +use crate::app::tab::Tab; +use crate::app::TextEditor; +use std::fs; +use std::path::PathBuf; + +pub(crate) fn new_file(app: &mut TextEditor) { + app.add_new_tab(); +} + +pub(crate) fn open_file(app: &mut TextEditor) { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Text files", &["*"]) + .pick_file() + { + match fs::read_to_string(&path) { + Ok(content) => { + // Check if the current active tab is empty/clean and can be replaced + let should_replace_current_tab = if let Some(active_tab) = app.get_active_tab() { + active_tab.file_path.is_none() + && active_tab.content.is_empty() + && !active_tab.is_modified + } else { + false + }; + + if should_replace_current_tab { + // Replace the current empty tab + if let Some(active_tab) = app.get_active_tab_mut() { + active_tab.content = content; + active_tab.file_path = Some(path.clone()); + active_tab.title = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Untitled") + .to_string(); + active_tab.mark_as_saved(); // This will set the hash and mark as not modified + } + } else { + // Create a new tab as before + let new_tab = Tab::new_with_file(content, path); + app.tabs.push(new_tab); + app.active_tab_index = app.tabs.len() - 1; + } + } + Err(err) => { + eprintln!("Failed to open file: {}", err); + } + } + } +} + +pub(crate) fn save_file(app: &mut TextEditor) { + if let Some(active_tab) = app.get_active_tab() { + if let Some(path) = &active_tab.file_path { + save_to_path(app, path.clone()); + } else { + save_as_file(app); + } + } +} + +pub(crate) fn save_as_file(app: &mut TextEditor) { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Text files", &["*"]) + .save_file() + { + save_to_path(app, path); + } +} + +pub(crate) fn save_to_path(app: &mut TextEditor, path: PathBuf) { + if let Some(active_tab) = app.get_active_tab_mut() { + match fs::write(&path, &active_tab.content) { + Ok(()) => { + active_tab.file_path = Some(path.clone()); + active_tab.title = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Untitled") + .to_string(); + active_tab.mark_as_saved(); + } + Err(err) => { + eprintln!("Failed to save file: {}", err); + } + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f6fedc7 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,26 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use eframe::egui; + +mod app; +mod io; +mod ui; +use app::{TextEditor, config::Config}; + +fn main() -> eframe::Result { + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_min_inner_size([600.0, 400.0]) + .with_title("C-Ext") + .with_app_id("io.lampnet.c-ext"), + ..Default::default() + }; + + let config = Config::load(); + + eframe::run_native( + "C-Ext", + options, + Box::new(move |cc| Ok(Box::new(TextEditor::from_config_with_context(config, cc)))), + ) +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..35cb22e --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,7 @@ +pub(crate) mod about_window; +pub(crate) mod central_panel; +pub(crate) mod find_window; +pub(crate) mod menu_bar; +pub(crate) mod preferences_window; +pub(crate) mod shortcuts_window; +pub(crate) mod tab_bar; diff --git a/src/ui/about_window.rs b/src/ui/about_window.rs new file mode 100644 index 0000000..1e573ca --- /dev/null +++ b/src/ui/about_window.rs @@ -0,0 +1,41 @@ +use crate::app::TextEditor; +use eframe::egui; + +pub(crate) fn about_window(app: &mut TextEditor, ctx: &egui::Context) { + let visuals = &ctx.style().visuals; + + egui::Window::new(format!( + "Candle's Editor (ced {})", + env!("CARGO_PKG_VERSION") + )) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .frame(egui::Frame { + fill: visuals.window_fill, + stroke: visuals.window_stroke, + corner_radius: egui::CornerRadius::same(8), + shadow: visuals.window_shadow, + inner_margin: egui::Margin::same(16), + outer_margin: egui::Margin::same(0), + }) + .show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new("A stupidly simple, responsive text editor.") + .size(14.0) + .weak(), + ); + + ui.add_space(12.0); + let visuals = ui.visuals(); + let close_button = egui::Button::new("Close") + .fill(visuals.widgets.inactive.bg_fill) + .stroke(visuals.widgets.inactive.bg_stroke); + + if ui.add(close_button).clicked() { + app.show_about = false; + } + }); + }); +} diff --git a/src/ui/central_panel.rs b/src/ui/central_panel.rs new file mode 100644 index 0000000..b7be3d2 --- /dev/null +++ b/src/ui/central_panel.rs @@ -0,0 +1,112 @@ +mod editor; +mod find_highlight; +mod line_numbers; + +use crate::app::TextEditor; +use eframe::egui; + +use self::editor::editor_view_ui; +use self::line_numbers::{get_visual_line_mapping, render_line_numbers}; + +pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { + let show_line_numbers = app.show_line_numbers; + let word_wrap = app.word_wrap; + let line_side = app.line_side; + let font_size = app.font_size; + + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ctx, |ui| { + let bg_color = ui.visuals().extreme_bg_color; + let panel_rect = ui.available_rect_before_wrap(); + ui.painter().rect_filled(panel_rect, 0.0, bg_color); + let editor_height = panel_rect.height(); + + if !show_line_numbers || app.get_active_tab().is_none() { + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + editor_view_ui(ui, app); + }); + return; + } + + let line_count = app.get_text_processing_result().line_count; + let editor_dimensions = app.calculate_editor_dimensions(ui); + let line_number_width = editor_dimensions.line_number_width; + + let visual_line_mapping = if word_wrap { + let available_text_width = editor_dimensions.text_width; + if let Some(active_tab) = app.get_active_tab() { + get_visual_line_mapping( + ui, + &active_tab.content, + available_text_width, + font_size, + ) + } else { + vec![] + } + } else { + vec![] + }; + + let line_numbers_widget = |ui: &mut egui::Ui| { + render_line_numbers( + ui, + line_count, + &visual_line_mapping, + line_number_width, + word_wrap, + font_size, + ); + }; + + let separator_widget = |ui: &mut egui::Ui| { + ui.add_space(3.0); + let separator_x = ui.cursor().left(); + let mut y_range = ui.available_rect_before_wrap().y_range(); + y_range.max += 2.0 * font_size; // Extend separator to cover more vertical space + ui.painter() + .vline(separator_x, y_range, ui.visuals().window_stroke); + ui.add_space(4.0); + }; + + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + if line_side { + // Line numbers on the right + let text_editor_width = editor_dimensions.text_width + editor_dimensions.total_reserved_width; + ui.allocate_ui_with_layout( + egui::vec2(text_editor_width, editor_height), + egui::Layout::left_to_right(egui::Align::TOP), + |ui| { + // Constrain editor to specific width to leave space for line numbers + ui.allocate_ui_with_layout( + egui::vec2(editor_dimensions.text_width, editor_height), + egui::Layout::left_to_right(egui::Align::TOP), + |ui| { + editor_view_ui(ui, app); + }, + ); + separator_widget(ui); + line_numbers_widget(ui); + }, + ); + } else { + // Line numbers on the left + let text_editor_width = editor_dimensions.text_width + editor_dimensions.total_reserved_width; + ui.allocate_ui_with_layout( + egui::vec2(text_editor_width, editor_height), + egui::Layout::left_to_right(egui::Align::TOP), + |ui| { + line_numbers_widget(ui); + separator_widget(ui); + editor_view_ui(ui, app); + }, + ); + } + }); + }); +} diff --git a/src/ui/central_panel/editor.rs b/src/ui/central_panel/editor.rs new file mode 100644 index 0000000..a014200 --- /dev/null +++ b/src/ui/central_panel/editor.rs @@ -0,0 +1,222 @@ +use crate::app::TextEditor; +use eframe::egui; + +use super::find_highlight::draw_find_highlight; + +pub(super) fn editor_view( + ui: &mut egui::Ui, + app: &mut TextEditor, +) -> (egui::Response, Option) { + let current_match_position = app.get_current_match_position(); + let show_find = app.show_find; + let prev_show_find = app.prev_show_find; + let show_preferences = app.show_preferences; + let show_about = app.show_about; + let show_shortcuts = app.show_shortcuts; + let word_wrap = app.word_wrap; + let font_size = app.font_size; + + // Check if reset zoom was requested in previous frame + let reset_zoom_key = egui::Id::new("editor_reset_zoom"); + let should_reset_zoom = ui.ctx().memory_mut(|mem| { + mem.data.get_temp::(reset_zoom_key).unwrap_or(false) + }); + + // Reset zoom if requested + 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); + }); + } + + if let Some(active_tab) = app.get_active_tab_mut() { + let bg_color = ui.visuals().extreme_bg_color; + let editor_rect = ui.available_rect_before_wrap(); + ui.painter().rect_filled(editor_rect, 0.0, bg_color); + + let desired_width = if word_wrap { + ui.available_width() + } else { + f32::INFINITY + }; + + let text_edit = egui::TextEdit::multiline(&mut active_tab.content) + .frame(false) + .font(egui::TextStyle::Monospace) + .code_editor() + .desired_width(desired_width) + .desired_rows(0) + .lock_focus(true) + .cursor_at_end(false) + .id(egui::Id::new("main_text_editor")); + + let output = text_edit.show(ui); + + // Store text length for context menu + let text_len = active_tab.content.len(); + + // Right-click context menu + output.response.context_menu(|ui| { + if ui.button("Cut").clicked() { + ui.ctx().input_mut(|i| i.events.push(egui::Event::Cut)); + ui.close_menu(); + } + if ui.button("Copy").clicked() { + ui.ctx().input_mut(|i| i.events.push(egui::Event::Copy)); + ui.close_menu(); + } + if ui.button("Paste").clicked() { + ui.ctx() + .send_viewport_cmd(egui::ViewportCommand::RequestPaste); + ui.close_menu(); + } + if ui.button("Delete").clicked() { + ui.ctx().input_mut(|i| { + i.events.push(egui::Event::Key { + key: egui::Key::Delete, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }) + }); + ui.close_menu(); + } + if ui.button("Select All").clicked() { + let text_edit_id = egui::Id::new("main_text_editor"); + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { + let select_all_range = egui::text::CCursorRange::two( + egui::text::CCursor::new(0), + egui::text::CCursor::new(text_len), + ); + state.cursor.set_char_range(Some(select_all_range)); + egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); + } + ui.close_menu(); + } + ui.separator(); + if ui.button("Reset Zoom").clicked() { + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(reset_zoom_key, true); + }); + ui.close_menu(); + } + }); + + let cursor_rect = if let Some(cursor_range) = output.state.cursor.char_range() { + let cursor_pos = cursor_range.primary.index; + let content = &active_tab.content; + + let text_up_to_cursor = &content[..cursor_pos.min(content.len())]; + let cursor_line = text_up_to_cursor.chars().filter(|&c| c == '\n').count(); + + let font_id = ui + .style() + .text_styles + .get(&egui::TextStyle::Monospace) + .unwrap_or(&egui::FontId::monospace(font_size)) + .clone(); + let line_height = ui.fonts(|fonts| fonts.row_height(&font_id)); + + let y_pos = output.response.rect.top() + (cursor_line as f32 * line_height); + let cursor_rect = egui::Rect::from_min_size( + egui::pos2(output.response.rect.left(), y_pos), + egui::vec2(2.0, line_height), + ); + Some(cursor_rect) + } else { + None + }; + + if !show_find && prev_show_find { + if let Some((start_pos, end_pos)) = current_match_position { + let text_edit_id = egui::Id::new("main_text_editor"); + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { + let cursor_range = egui::text::CCursorRange::two( + egui::text::CCursor::new(start_pos), + egui::text::CCursor::new(end_pos), + ); + state.cursor.set_char_range(Some(cursor_range)); + egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); + } + } + } + + if show_find { + if let Some((start_pos, end_pos)) = current_match_position { + draw_find_highlight( + ui, + &active_tab.content, + start_pos, + end_pos, + output.response.rect, + font_size, + ); + } + } + + if output.response.changed() { + active_tab.update_modified_state(); + app.find_matches.clear(); + app.current_match_index = None; + } + + if !output.response.has_focus() + && !show_preferences + && !show_about + && !show_shortcuts + && !show_find + { + output.response.request_focus(); + } + (output.response, cursor_rect) + } else { + (ui.label("No file open, how did you get here?"), None) + } +} + +pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) { + let word_wrap = app.word_wrap; + + if word_wrap { + let (_response, _cursor_rect) = editor_view(ui, app); + } else { + let estimated_width = app.calculate_content_based_width(ui); + let output = egui::ScrollArea::horizontal() + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.allocate_ui_with_layout( + egui::Vec2::new(estimated_width, ui.available_height()), + egui::Layout::left_to_right(egui::Align::TOP), + |ui| editor_view(ui, app), + ) + }); + + let editor_response = &output.inner.inner.0; + if let Some(cursor_rect) = output.inner.inner.1 { + let text_edit_id = egui::Id::new("main_text_editor"); + let current_cursor_pos = + if let Some(state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { + state.cursor.char_range().map(|range| range.primary.index) + } else { + None + }; + + let cursor_moved = current_cursor_pos != app.previous_cursor_position; + let text_changed = editor_response.changed(); + let should_scroll = (cursor_moved || text_changed) + && { + let visible_area = ui.clip_rect(); + !visible_area.intersects(cursor_rect) + }; + + if should_scroll { + ui.scroll_to_rect(cursor_rect, Some(egui::Align::Center)); + } + + app.previous_cursor_position = current_cursor_pos; + } + } +} diff --git a/src/ui/central_panel/find_highlight.rs b/src/ui/central_panel/find_highlight.rs new file mode 100644 index 0000000..6354c34 --- /dev/null +++ b/src/ui/central_panel/find_highlight.rs @@ -0,0 +1,83 @@ +use eframe::egui; + +pub(super) fn draw_find_highlight( + ui: &mut egui::Ui, + content: &str, + start_pos: usize, + end_pos: usize, + editor_rect: egui::Rect, + font_size: f32, +) { + let font_id = ui + .style() + .text_styles + .get(&egui::TextStyle::Monospace) + .unwrap_or(&egui::FontId::monospace(font_size)) + .clone(); + + let text_up_to_start = &content[..start_pos.min(content.len())]; + + let start_line = text_up_to_start.chars().filter(|&c| c == '\n').count(); + + let line_start_byte_pos = text_up_to_start.rfind('\n').map(|pos| pos + 1).unwrap_or(0); + let line_start_char_pos = content[..line_start_byte_pos].chars().count(); + let start_char_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 line_height = ui.fonts(|fonts| fonts.row_height(&font_id)); + + let horizontal_margin = ui.spacing().button_padding.x - 4.0; + let vertical_margin = ui.spacing().button_padding.y - 1.0; + let text_area_left = editor_rect.left() + horizontal_margin; + let text_area_top = editor_rect.top() + vertical_margin; + + let text_before_width = ui.fonts(|fonts| { + fonts + .layout( + text_before_match, + font_id.clone(), + egui::Color32::WHITE, + f32::INFINITY, + ) + .size() + .x + }); + + let start_y = text_area_top + (start_line as f32 * line_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.clone(), + 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), + ); + + ui.painter().rect_filled( + highlight_rect, + 0.0, + ui.visuals().selection.bg_fill, + ); + } +} diff --git a/src/ui/central_panel/line_numbers.rs b/src/ui/central_panel/line_numbers.rs new file mode 100644 index 0000000..b0d1a87 --- /dev/null +++ b/src/ui/central_panel/line_numbers.rs @@ -0,0 +1,119 @@ +use eframe::egui; + +thread_local! { + static VISUAL_LINE_MAPPING_CACHE: std::cell::RefCell>)>> = std::cell::RefCell::new(None); +} + +pub(super) fn get_visual_line_mapping( + ui: &egui::Ui, + content: &str, + available_width: f32, + font_size: f32, +) -> Vec> { + let should_recalculate = VISUAL_LINE_MAPPING_CACHE.with(|cache| { + if let Some((cached_content, cached_width, _)) = cache.borrow().as_ref() { + content != cached_content || available_width != *cached_width + } else { + true + } + }); + + if should_recalculate { + let visual_lines = calculate_visual_line_mapping(ui, content, available_width, font_size); + VISUAL_LINE_MAPPING_CACHE.with(|cache| { + *cache.borrow_mut() = Some((content.to_owned(), available_width, visual_lines)); + }); + } + + VISUAL_LINE_MAPPING_CACHE.with(|cache| { + cache + .borrow() + .as_ref() + .map(|(_, _, mapping)| mapping.clone()) + .unwrap_or_default() + }) +} + +fn calculate_visual_line_mapping( + ui: &egui::Ui, + content: &str, + available_width: f32, + font_size: f32, +) -> Vec> { + let mut visual_lines = Vec::new(); + let font_id = egui::FontId::monospace(font_size); + + for (line_num, line) in content.lines().enumerate() { + if line.is_empty() { + visual_lines.push(Some(line_num + 1)); + continue; + } + + let galley = ui.fonts(|fonts| { + fonts.layout( + line.to_string(), + font_id.clone(), + egui::Color32::WHITE, + available_width, + ) + }); + + let wrapped_line_count = galley.rows.len().max(1); + + visual_lines.push(Some(line_num + 1)); + + for _ in 1..wrapped_line_count { + visual_lines.push(None); + } + } + + visual_lines +} + +pub(super) fn render_line_numbers( + ui: &mut egui::Ui, + line_count: usize, + visual_line_mapping: &[Option], + line_number_width: f32, + word_wrap: bool, + font_size: f32, +) { + ui.vertical(|ui| { + ui.set_width(line_number_width); + ui.spacing_mut().item_spacing.y = 0.0; + + let text_color = ui.visuals().weak_text_color(); + let bg_color = ui.visuals().extreme_bg_color; + + let line_numbers_rect = ui.available_rect_before_wrap(); + ui.painter() + .rect_filled(line_numbers_rect, 0.0, bg_color); + + let font_id = egui::FontId::monospace(font_size); + let line_count_width = line_count.to_string().len(); + + if word_wrap { + for line_number_opt in visual_line_mapping { + let text = if let Some(line_number) = line_number_opt { + format!("{:>width$}", line_number, width = line_count_width) + } else { + " ".repeat(line_count_width) + }; + ui.label( + egui::RichText::new(text) + .font(font_id.clone()) + .color(text_color), + ); + } + } else { + for i in 1..=line_count { + let text = format!("{:>width$}", i, width = line_count_width); + ui.label( + egui::RichText::new(text) + .font(font_id.clone()) + .color(text_color), + ); + } + } + }); +} diff --git a/src/ui/find_window.rs b/src/ui/find_window.rs new file mode 100644 index 0000000..5dfdec7 --- /dev/null +++ b/src/ui/find_window.rs @@ -0,0 +1,121 @@ +use crate::app::TextEditor; +use eframe::egui; + +pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) { + let visuals = &ctx.style().visuals; + + let mut should_close = false; + let mut query_changed = false; + + egui::Window::new("Find") + .collapsible(false) + .resizable(false) + .movable(true) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .frame(egui::Frame { + fill: visuals.window_fill, + stroke: visuals.window_stroke, + corner_radius: egui::CornerRadius::same(8), + shadow: visuals.window_shadow, + inner_margin: egui::Margin::same(16), + outer_margin: egui::Margin::same(0), + }) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.set_min_width(300.0); + + ui.horizontal(|ui| { + ui.label("Find:"); + let response = ui.add( + egui::TextEdit::singleline(&mut app.find_query) + .desired_width(200.0) + .hint_text("Enter search text..."), + ); + + if response.changed() { + query_changed = true; + } + + if !response.has_focus() { + response.request_focus(); + } + + if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + app.find_next(); + response.request_focus(); + } + }); + + ui.add_space(8.0); + + ui.horizontal(|ui| { + let case_sensitive_changed = ui + .checkbox(&mut app.case_sensitive_search, "Case sensitive") + .changed(); + if case_sensitive_changed { + query_changed = true; + } + }); + + ui.add_space(8.0); + + ui.horizontal(|ui| { + let match_text = if app.find_matches.is_empty() { + if app.find_query.is_empty() { + "".to_string() + } else { + "No matches found".to_string() + } + } else if let Some(current) = app.current_match_index { + format!("{} of {} matches", current + 1, app.find_matches.len()) + } else { + format!("{} matches found", app.find_matches.len()) + }; + + ui.label(egui::RichText::new(match_text).weak()); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("✕").clicked() { + should_close = true; + } + + ui.add_space(4.0); + + let next_enabled = !app.find_matches.is_empty(); + ui.add_enabled_ui(next_enabled, |ui| { + if ui.button("Next").clicked() { + app.find_next(); + } + }); + + let prev_enabled = !app.find_matches.is_empty(); + ui.add_enabled_ui(prev_enabled, |ui| { + if ui.button("Previous").clicked() { + app.find_previous(); + } + }); + }); + }); + }); + }); + + if query_changed { + app.update_find_matches(); + } + + if should_close { + app.show_find = false; + } + + ctx.input(|i| { + if i.key_pressed(egui::Key::Escape) { + app.show_find = false; + } else if i.key_pressed(egui::Key::F3) { + if i.modifiers.shift { + app.find_previous(); + } else { + app.find_next(); + } + } + }); +} diff --git a/src/ui/menu_bar.rs b/src/ui/menu_bar.rs new file mode 100644 index 0000000..2c41cc8 --- /dev/null +++ b/src/ui/menu_bar.rs @@ -0,0 +1,271 @@ +use crate::{app::TextEditor, io}; +use eframe::egui::{self, Frame}; + +pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { + let now = std::time::Instant::now(); + + let should_stay_stable = app + .menu_bar_stable_until + .map(|until| now < until) + .unwrap_or(false); + + let should_show_menubar = !app.auto_hide_toolbar || { + if app.menu_interaction_active { + app.menu_bar_stable_until = Some(now + std::time::Duration::from_millis(500)); + true + } else if should_stay_stable { + true + } else if let Some(pointer_pos) = ctx.pointer_hover_pos() { + let in_menu_trigger_area = pointer_pos.y < 10.0; + + if in_menu_trigger_area { + app.menu_bar_stable_until = Some(now + std::time::Duration::from_millis(300)); + true + } else { + false + } + } else { + false + } + }; + + app.menu_interaction_active = false; + + if should_show_menubar { + let frame = Frame::NONE.fill(ctx.style().visuals.panel_fill); + egui::TopBottomPanel::top("menubar") + .frame(frame) + .show(ctx, |ui| { + let menu_bar_rect = ui.available_rect_before_wrap(); + if let Some(pointer_pos) = ctx.pointer_hover_pos() { + if menu_bar_rect.contains(pointer_pos) { + app.menu_interaction_active = true; + } + } + + egui::menu::bar(ui, |ui| { + ui.menu_button("File", |ui| { + app.menu_interaction_active = true; + if ui.button("New").clicked() { + io::new_file(app); + ui.close_menu(); + } + if ui.button("Open...").clicked() { + io::open_file(app); + ui.close_menu(); + } + ui.separator(); + if ui.button("Save").clicked() { + io::save_file(app); + ui.close_menu(); + } + if ui.button("Save As...").clicked() { + io::save_as_file(app); + ui.close_menu(); + } + ui.separator(); + if ui.button("Preferences").clicked() { + app.show_preferences = true; + ui.close_menu(); + } + if ui.button("Exit").clicked() { + app.request_quit(ctx); + ui.close_menu(); + } + }); + + ui.menu_button("Edit", |ui| { + app.menu_interaction_active = true; + if ui.button("Cut").clicked() { + ui.ctx().input_mut(|i| i.events.push(egui::Event::Cut)); + ui.close_menu(); + } + if ui.button("Copy").clicked() { + ui.ctx().input_mut(|i| i.events.push(egui::Event::Copy)); + ui.close_menu(); + } + if ui.button("Paste").clicked() { + ui.ctx() + .send_viewport_cmd(egui::ViewportCommand::RequestPaste); + ui.close_menu(); + } + if ui.button("Delete").clicked() { + ui.ctx().input_mut(|i| { + i.events.push(egui::Event::Key { + key: egui::Key::Delete, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }) + }); + ui.close_menu(); + } + if ui.button("Select All").clicked() { + let text_edit_id = egui::Id::new("main_text_editor"); + if let Some(mut state) = + egui::TextEdit::load_state(ui.ctx(), text_edit_id) + { + if let Some(active_tab) = app.get_active_tab() { + let text_len = active_tab.content.len(); + let select_all_range = egui::text::CCursorRange::two( + egui::text::CCursor::new(0), + egui::text::CCursor::new(text_len), + ); + state.cursor.set_char_range(Some(select_all_range)); + egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); + } + } + ui.close_menu(); + } + ui.separator(); + if ui.button("Undo").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_mut() { + let current_state = ( + state.cursor.char_range().unwrap_or_default(), + active_tab.content.clone(), + ); + let mut undoer = state.undoer(); + if let Some((cursor_range, content)) = + undoer.undo(¤t_state) + { + active_tab.content = content.clone(); + state.cursor.set_char_range(Some(*cursor_range)); + state.set_undoer(undoer); + egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); + active_tab.update_modified_state(); + } + } + } + ui.close_menu(); + } + if ui.button("Redo").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_mut() { + let current_state = ( + state.cursor.char_range().unwrap_or_default(), + active_tab.content.clone(), + ); + let mut undoer = state.undoer(); + if let Some((cursor_range, content)) = + undoer.redo(¤t_state) + { + active_tab.content = content.clone(); + state.cursor.set_char_range(Some(*cursor_range)); + state.set_undoer(undoer); + egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); + active_tab.update_modified_state(); + } + } + } + ui.close_menu(); + } + }); + + ui.menu_button("View", |ui| { + app.menu_interaction_active = true; + if ui + .checkbox(&mut app.show_line_numbers, "Toggle Line Numbers") + .clicked() + { + app.save_config(); + ui.close_menu(); + } + if ui + .checkbox(&mut app.word_wrap, "Toggle Word Wrap") + .clicked() + { + app.save_config(); + ui.close_menu(); + } + if ui + .checkbox(&mut app.auto_hide_toolbar, "Auto Hide Toolbar") + .clicked() + { + app.save_config(); + ui.close_menu(); + } + + ui.separator(); + + if ui.button("Reset Zoom").clicked() { + app.zoom_factor = 1.0; + ctx.set_zoom_factor(1.0); + ui.close_menu(); + } + + ui.separator(); + + ui.menu_button("Appearance", |ui| { + app.menu_interaction_active = true; + let current_theme = app.theme; + + if ui + .radio_value( + &mut app.theme, + crate::app::theme::Theme::System, + "System", + ) + .clicked() + { + if current_theme != crate::app::theme::Theme::System { + app.set_theme(ctx); + } + ui.close_menu(); + } + if ui + .radio_value( + &mut app.theme, + crate::app::theme::Theme::Light, + "Light", + ) + .clicked() + { + if current_theme != crate::app::theme::Theme::Light { + app.set_theme(ctx); + } + ui.close_menu(); + } + if ui + .radio_value(&mut app.theme, crate::app::theme::Theme::Dark, "Dark") + .clicked() + { + if current_theme != crate::app::theme::Theme::Dark { + app.set_theme(ctx); + } + ui.close_menu(); + } + ui.separator(); + if ui.radio_value(&mut app.line_side, false, "Left").clicked() { + app.save_config(); + ui.close_menu(); + } + if ui.radio_value(&mut app.line_side, true, "Right").clicked() { + app.save_config(); + ui.close_menu(); + } + }); + }); + + ui.menu_button("Help", |ui| { + app.menu_interaction_active = true; + if ui.button("Shortcuts").clicked() { + app.show_shortcuts = true; + ui.close_menu(); + } + if ui.button("About").clicked() { + app.show_about = true; + ui.close_menu(); + } + }); + }); + }); + } +} diff --git a/src/ui/preferences_window.rs b/src/ui/preferences_window.rs new file mode 100644 index 0000000..105f309 --- /dev/null +++ b/src/ui/preferences_window.rs @@ -0,0 +1,149 @@ +use crate::app::TextEditor; +use eframe::egui; + +pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { + let visuals = &ctx.style().visuals; + let screen_rect = ctx.screen_rect(); + let window_width = (screen_rect.width() * 0.6).min(400.0).max(300.0); + let window_height = (screen_rect.height() * 0.7).min(500.0).max(250.0); + let max_size = egui::Vec2::new(window_width, window_height); + + egui::Window::new("Preferences") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .default_open(true) + .max_size(max_size) + .frame(egui::Frame { + fill: visuals.window_fill, + stroke: visuals.window_stroke, + corner_radius: egui::CornerRadius::same(8), + shadow: visuals.window_shadow, + inner_margin: egui::Margin::same(16), + outer_margin: egui::Margin::same(0), + }) + .show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.heading("Font Settings"); + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.label("Font Family:"); + ui.add_space(5.0); + + let mut changed = false; + egui::ComboBox::from_id_salt("font_family") + .selected_text(&app.font_family) + .show_ui(ui, |ui| { + if ui + .selectable_value( + &mut app.font_family, + "Proportional".to_string(), + "Proportional", + ) + .clicked() + { + changed = true; + } + if ui + .selectable_value( + &mut app.font_family, + "Monospace".to_string(), + "Monospace", + ) + .clicked() + { + changed = true; + } + }); + + if changed { + app.apply_font_settings(ctx); + } + }); + + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.label("Font Size:"); + ui.add_space(5.0); + + if app.font_size_input.is_none() { + app.font_size_input = Some(app.font_size.to_string()); + } + + let mut font_size_text = app.font_size_input.as_ref().unwrap().clone(); + let response = ui.add( + egui::TextEdit::singleline(&mut font_size_text) + .desired_width(50.0) + .hint_text("14") + .id(egui::Id::new("font_size_input")), + ); + + app.font_size_input = Some(font_size_text.clone()); + + if response.clicked() { + response.request_focus(); + } + + ui.label("px"); + + if response.lost_focus() { + if let Ok(new_size) = font_size_text.parse::() { + let clamped_size = new_size.clamp(8.0, 32.0); + if (app.font_size - clamped_size).abs() > 0.1 { + app.font_size = clamped_size; + app.apply_font_settings(ctx); + } + } + app.font_size_input = None; + } + }); + + ui.add_space(12.0); + + ui.separator(); + ui.add_space(8.0); + ui.label("Preview:"); + ui.add_space(4.0); + + egui::ScrollArea::vertical() + .max_height(150.0) + .show(ui, |ui| { + egui::Frame::new() + .fill(visuals.code_bg_color) + .stroke(visuals.widgets.noninteractive.bg_stroke) + .inner_margin(egui::Margin::same(8)) + .show(ui, |ui| { + let preview_font = egui::FontId::new( + app.font_size, + match app.font_family.as_str() { + "Monospace" => egui::FontFamily::Monospace, + _ => egui::FontFamily::Proportional, + }, + ); + ui.label( + egui::RichText::new("The quick brown fox jumps over the lazy dog.") + .font(preview_font.clone()), + ); + ui.label( + egui::RichText::new("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + .font(preview_font.clone()), + ); + ui.label( + egui::RichText::new("abcdefghijklmnopqrstuvwxyz") + .font(preview_font.clone()), + ); + ui.label(egui::RichText::new("1234567890 !@#$%^&*()").font(preview_font)); + }); + }); + + ui.add_space(12.0); + + if ui.button("Close").clicked() { + app.show_preferences = false; + app.font_size_input = None; + } + }); + }); +} diff --git a/src/ui/shortcuts_window.rs b/src/ui/shortcuts_window.rs new file mode 100644 index 0000000..2c67198 --- /dev/null +++ b/src/ui/shortcuts_window.rs @@ -0,0 +1,102 @@ +use crate::app::TextEditor; +use eframe::egui; + +fn render_shortcuts_content(ui: &mut egui::Ui) { + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new("Navigation").size(18.0).strong()); + ui.label(egui::RichText::new("Ctrl + N: New").size(14.0)); + ui.label(egui::RichText::new("Ctrl + O: Open").size(14.0)); + ui.label(egui::RichText::new("Ctrl + S: Save").size(14.0)); + ui.label(egui::RichText::new("Ctrl + Shift + S: Save As").size(14.0)); + ui.label(egui::RichText::new("Ctrl + T: New Tab").size(14.0)); + ui.label(egui::RichText::new("Ctrl + Tab: Next Tab").size(14.0)); + ui.label(egui::RichText::new("Ctrl + Shift + Tab: Last Tab").size(14.0)); + ui.add_space(16.0); + ui.separator(); + + ui.label(egui::RichText::new("Editing").size(18.0).strong()); + ui.label(egui::RichText::new("Ctrl + Z: Undo").size(14.0)); + ui.label(egui::RichText::new("Ctrl + Shift + Z: Redo").size(14.0)); + ui.label(egui::RichText::new("Ctrl + X: Cut").size(14.0)); + ui.label(egui::RichText::new("Ctrl + C: Copy").size(14.0)); + ui.label(egui::RichText::new("Ctrl + V: Paste").size(14.0)); + ui.label(egui::RichText::new("Ctrl + A: Select All").size(14.0)); + ui.label(egui::RichText::new("Ctrl + D: Delete Line").size(14.0)); + + ui.add_space(16.0); + ui.separator(); + ui.label(egui::RichText::new("Views").size(18.0).strong()); + ui.label(egui::RichText::new("Ctrl + L: Toggle Line Numbers").size(14.0)); + ui.label( + egui::RichText::new("Ctrl + Shift + L: Change Line Number Side").size(14.0), + ); + ui.label(egui::RichText::new("Ctrl + K: Toggle Word Wrap").size(14.0)); + ui.label(egui::RichText::new("Ctrl + H: Toggle Auto Hide Toolbar").size(14.0)); + ui.label(egui::RichText::new("Ctrl + P: Preferences").size(14.0)); + ui.label(egui::RichText::new("Ctrl + =/-: Increase/Decrease Font Size").size(14.0)); + ui.label(egui::RichText::new("Ctrl + Shift + =/-: Zoom In/Out").size(14.0)); + + // ui.label( + // egui::RichText::new("Ctrl + Shift + .: Toggle Vim Mode") + // .size(14.0) + // ); + // ui.label( + // egui::RichText::new("Ctrl + .: Toggle Vim Mode") + // .size(14.0) + // ); + ui.add_space(12.0); + }); +} + +pub(crate) fn shortcuts_window(app: &mut TextEditor, ctx: &egui::Context) { + let visuals = &ctx.style().visuals; + let screen_rect = ctx.screen_rect(); + + // Calculate appropriate window size that always fits nicely in the main window + let window_width = (screen_rect.width() * 0.6).min(400.0).max(300.0); + let window_height = (screen_rect.height() * 0.7).min(500.0).max(250.0); + + egui::Window::new("Shortcuts") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .fixed_size([window_width, window_height]) + .frame(egui::Frame { + fill: visuals.window_fill, + stroke: visuals.window_stroke, + corner_radius: egui::CornerRadius::same(8), + shadow: visuals.window_shadow, + inner_margin: egui::Margin::same(16), + outer_margin: egui::Margin::same(0), + }) + .show(ctx, |ui| { + ui.vertical(|ui| { + // Scrollable content area + let available_height = ui.available_height() - 40.0; // Reserve space for close button + ui.allocate_ui_with_layout( + [ui.available_width(), available_height].into(), + egui::Layout::top_down(egui::Align::Center), + |ui| { + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + render_shortcuts_content(ui); + }); + }, + ); + + // Fixed close button at bottom + ui.vertical_centered(|ui| { + ui.add_space(8.0); + let visuals = ui.visuals(); + let close_button = egui::Button::new("Close") + .fill(visuals.widgets.inactive.bg_fill) + .stroke(visuals.widgets.inactive.bg_stroke); + + if ui.add(close_button).clicked() { + app.show_shortcuts = false; + } + }); + }); + }); +} diff --git a/src/ui/tab_bar.rs b/src/ui/tab_bar.rs new file mode 100644 index 0000000..d09b297 --- /dev/null +++ b/src/ui/tab_bar.rs @@ -0,0 +1,88 @@ +use crate::app::{state::UnsavedAction, TextEditor}; +use eframe::egui::{self, Frame}; + +pub(crate) fn tab_bar(app: &mut TextEditor, ctx: &egui::Context) { + let frame = Frame::NONE.fill(ctx.style().visuals.panel_fill); + let response = egui::TopBottomPanel::top("tab_bar") + .frame(frame) + .show(ctx, |ui| { + ui.horizontal(|ui| { + let mut tab_to_close_unmodified = None; + let mut tab_to_close_modified = None; + let mut tab_to_switch = None; + let mut add_new_tab = false; + + let tabs_len = app.tabs.len(); + let active_tab_index = app.active_tab_index; + + let tabs_info: Vec<(String, bool)> = app + .tabs + .iter() + .map(|tab| (tab.get_display_title(), tab.is_modified)) + .collect(); + + for (i, (title, is_modified)) in tabs_info.iter().enumerate() { + let is_active = i == active_tab_index; + + let mut label_text = if is_active { + egui::RichText::new(title).strong() + } else { + egui::RichText::new(title).color(ui.visuals().weak_text_color()) + }; + + if *is_modified { + label_text = label_text.italics(); + } + + let tab_response = + ui.add(egui::Label::new(label_text).sense(egui::Sense::click())); + if tab_response.clicked() { + tab_to_switch = Some(i); + } + + if tabs_len > 1 { + let visuals = ui.visuals(); + let close_button = egui::Button::new("×") + .small() + .fill(visuals.panel_fill) + .stroke(egui::Stroke::new(0.0, egui::Color32::from_rgb(0, 0, 0))); + let close_response = ui.add(close_button); + if close_response.clicked() { + if *is_modified { + tab_to_close_modified = Some(i); + } else { + tab_to_close_unmodified = Some(i); + } + } + } + + ui.separator(); + } + + let visuals = ui.visuals(); + let add_button = egui::Button::new("+") + .small() + .fill(visuals.panel_fill) + .stroke(egui::Stroke::new(0.0, egui::Color32::from_rgb(0, 0, 0))); + if ui.add(add_button).clicked() { + add_new_tab = true; + } + + if let Some(tab_index) = tab_to_switch { + app.switch_to_tab(tab_index); + } + if let Some(tab_index) = tab_to_close_unmodified { + app.close_tab(tab_index); + } + if let Some(tab_index) = tab_to_close_modified { + app.switch_to_tab(tab_index); + app.pending_unsaved_action = Some(UnsavedAction::CloseTab(tab_index)); + } + if add_new_tab { + app.add_new_tab(); + } + }); + }); + + app.tab_bar_rect = Some(response.response.rect); +}