diff --git a/Cargo.toml b/Cargo.toml index 48eea41..63ab481 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,19 @@ [package] name = "ced" -version = "0.0.9" +version = "0.1.3" edition = "2024" [dependencies] eframe = "0.32" egui = "0.32" +egui_extras = { version = "0.32", features = ["syntect"] } serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0" +serde_json = "1.0.141" rfd = "0.15.4" toml = "0.9.2" dirs = "6.0" libc = "0.2.174" +syntect = "5.2.0" +plist = "1.7.4" diffy = "0.4.2" +uuid = { version = "1.0", features = ["v4"] } diff --git a/README.md b/README.md index 10272ef..e1aa170 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ There is a disturbing lack of simple GUI text editors available on Linux nativel ## Features * Sane text editing with standard keybindings (`Ctrl+A`, `Ctrl+C`, etc.). -* Opens with a blank slate for quick typing, remember Notepad? +* Choose between opening fresh every time, like Notepad, or maintaining a consistent state like Notepad++. * Separate UI zoom that doesn't affect font size (`Ctrl+Shift` + `+`/`-`). * Ricers rejoice, your `pywal` colors will be used! * Weirdly smooth typing experience. @@ -39,6 +39,7 @@ sudo install -Dm644 ced.desktop /usr/share/applications/ced.desktop Here is an example `config.toml`: ```toml +state_cache = true auto_hide_toolbar = false show_line_numbers = false word_wrap = false @@ -46,15 +47,18 @@ theme = "System" line_side = false font_family = "Monospace" font_size = 16.0 +syntax_highlighting = true ``` ### Options | Option | Default | Description | |--------|---------|-------------| +| `state_cache` | `false` | If `true`, open files will have their unsaved changes cached and will be automatically opened when starting a new session. | | `auto_hide_toolbar` | `false` | If `true`, the menu bar at the top will be hidden. Move your mouse to the top of the window to reveal it. | | `hide_tab_bar` | 'true' | If `false`, a separate tab bar will be drawn below the toolbar. | | `show_line_numbers` | `false` | If `true`, line numbers will be displayed on the side specified by `line_side`. | +| `syntax_highlighting` | `false` | If `true`, text will be highlighted based on detected language. | | `line_side` | `false` | If `false`, line numbers are on the left. If `true`, they are on the right. | | `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. | @@ -65,9 +69,7 @@ font_size = 16.0 In order of importance. | Feature | Info | | ------- | ---- | -| **Find/Replace:** | Functioning. | -| **State/Cache:** | A toggleable option to keep an application state and prevent "Quit without saving" warnings. | -| **Syntax Highlighting/LSP:** | Looking at allowing you to use/attach your own tools for this. | +| **LSP:** | Looking at allowing you to use/attach your own tools for this. | | **Choose Font** | More than just Monospace/Proportional. | | **Vim Mode:** | It's in-escapable. | | **CLI Mode:** | 💀 | diff --git a/src/app/state/config.rs b/src/app/state/config.rs index adb36fd..fad9e93 100644 --- a/src/app/state/config.rs +++ b/src/app/state/config.rs @@ -1,6 +1,8 @@ use super::editor::TextEditor; use crate::app::config::Config; use crate::app::theme; +use crate::io; +use std::path::PathBuf; impl TextEditor { pub fn from_config(config: Config) -> Self { @@ -19,14 +21,44 @@ impl TextEditor { } } - pub fn from_config_with_context(config: Config, cc: &eframe::CreationContext<'_>) -> Self { + pub fn from_config_with_context( + config: Config, + cc: &eframe::CreationContext<'_>, + initial_paths: Vec, + ) -> Self { let mut editor = Self::from_config(config); - - // Load state cache if enabled + if let Err(e) = editor.load_state_cache() { eprintln!("Failed to load state cache: {e}"); } - + + if !initial_paths.is_empty() { + let mut opened_any = false; + + for path in initial_paths { + if path.is_file() { + match io::open_file_from_path(&mut editor, path.clone()) { + Ok(()) => opened_any = true, + Err(e) => eprintln!("Error opening file {}: {}", path.display(), e), + } + } else if path.is_dir() { + match io::open_files_from_directory(&mut editor, path.clone()) { + Ok(count) => { + opened_any = true; + println!("Opened {} files from directory {}", count, path.display()); + } + Err(e) => eprintln!("Error opening directory {}: {}", path.display(), e), + } + } else { + eprintln!("Warning: Path does not exist: {}", path.display()); + } + } + + if opened_any { + editor.active_tab_index = editor.tabs.len().saturating_sub(1); + } + } + theme::apply(editor.theme, &cc.egui_ctx); cc.egui_ctx.options_mut(|o| o.zoom_with_keyboard = false); diff --git a/src/app/state/find.rs b/src/app/state/find.rs index bae0f3f..0e30610 100644 --- a/src/app/state/find.rs +++ b/src/app/state/find.rs @@ -30,7 +30,6 @@ impl TextEditor { let search_slice = if search_content.is_char_boundary(start) { &search_content[start..] } else { - // Find next valid boundary while start < search_content.len() && !search_content.is_char_boundary(start) { start += 1; } @@ -45,7 +44,6 @@ impl TextEditor { self.find_matches .push((absolute_pos, absolute_pos + query.len())); - // Advance to next valid character boundary instead of just +1 start = absolute_pos + 1; while start < search_content.len() && !search_content.is_char_boundary(start) { start += 1; diff --git a/src/app/state/state_cache.rs b/src/app/state/state_cache.rs index f19e4f1..4cda33d 100644 --- a/src/app/state/state_cache.rs +++ b/src/app/state/state_cache.rs @@ -1,11 +1,12 @@ use super::editor::TextEditor; -use crate::app::tab::{Tab, compute_content_hash}; +use crate::app::tab::{compute_content_hash, Tab}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CachedTab { - pub diff: Option, + pub diff_file: Option, pub full_content: Option, // This is used for 'new files' that don't have a path pub file_path: Option, pub is_modified: bool, @@ -20,18 +21,40 @@ pub struct StateCache { pub tab_counter: usize, } +fn create_diff_file(diff_content: &str) -> Result> { + let diffs_dir = TextEditor::diffs_cache_dir().ok_or("Cannot determine cache directory")?; + std::fs::create_dir_all(&diffs_dir)?; + + let diff_filename = format!("{}.diff", Uuid::new_v4()); + let diff_path = diffs_dir.join(diff_filename); + + std::fs::write(&diff_path, diff_content)?; + Ok(diff_path) +} + +fn load_diff_file(diff_path: &PathBuf) -> Result> { + Ok(std::fs::read_to_string(diff_path)?) +} + impl From<&Tab> for CachedTab { fn from(tab: &Tab) -> Self { if let Some(file_path) = &tab.file_path { let original_content = std::fs::read_to_string(file_path).unwrap_or_default(); - let diff = if tab.is_modified { - Some(diffy::create_patch(&original_content, &tab.content).to_string()) + let diff_file = if tab.is_modified { + let diff_content = diffy::create_patch(&original_content, &tab.content); + match create_diff_file(&diff_content.to_string()) { + Ok(path) => Some(path), + Err(e) => { + eprintln!("Warning: Failed to create diff file: {}", e); + None + } + } } else { None }; - + Self { - diff, + diff_file, full_content: None, file_path: tab.file_path.clone(), is_modified: tab.is_modified, @@ -40,7 +63,7 @@ impl From<&Tab> for CachedTab { } } else { Self { - diff: None, + diff_file: None, full_content: Some(tab.content.clone()), file_path: None, is_modified: tab.is_modified, @@ -55,41 +78,53 @@ impl From for Tab { fn from(cached: CachedTab) -> Self { if let Some(file_path) = cached.file_path { let original_content = std::fs::read_to_string(&file_path).unwrap_or_default(); - let current_content = if let Some(diff_str) = cached.diff { - match diffy::Patch::from_str(&diff_str) { - Ok(patch) => { - match diffy::apply(&original_content, &patch) { - Ok(content) => content, + let current_content = if let Some(diff_path) = cached.diff_file { + match load_diff_file(&diff_path) { + Ok(diff_content) => { + match diffy::Patch::from_str(&diff_content) { + Ok(patch) => match diffy::apply(&original_content, &patch) { + Ok(content) => content, + Err(_) => { + eprintln!("Warning: Failed to apply diff for {}, using original content", + file_path.display()); + original_content + } + }, Err(_) => { - eprintln!("Warning: Failed to apply diff for {}, using original content", - file_path.display()); + eprintln!( + "Warning: Failed to parse diff for {}, using original content", + file_path.display() + ); original_content } } } - Err(_) => { - eprintln!("Warning: Failed to parse diff for {}, using original content", - file_path.display()); + Err(e) => { + eprintln!( + "Warning: Failed to load diff file {:?}: {}, using original content", + diff_path, e + ); original_content } } } else { original_content }; - - let original_hash = compute_content_hash(&std::fs::read_to_string(&file_path).unwrap_or_default()); + + let original_hash = + compute_content_hash(&std::fs::read_to_string(&file_path).unwrap_or_default()); let expected_hash = cached.original_content_hash; - + let mut tab = Tab::new_with_file(current_content, file_path); tab.title = cached.title; - + if original_hash != expected_hash { tab.is_modified = true; } else { tab.is_modified = cached.is_modified; tab.original_content_hash = cached.original_content_hash; } - + tab } else { let content = cached.full_content.unwrap_or_default(); @@ -116,13 +151,44 @@ impl TextEditor { Some(cache_dir.join("state.json")) } + pub fn diffs_cache_dir() -> Option { + let cache_dir = if let Some(cache_dir) = dirs::cache_dir() { + cache_dir.join(env!("CARGO_PKG_NAME")) + } else if let Some(home_dir) = dirs::home_dir() { + home_dir.join(".cache").join(env!("CARGO_PKG_NAME")) + } else { + return None; + }; + + Some(cache_dir.join("diffs")) + } + + fn cleanup_orphaned_diffs( + active_diff_files: &[PathBuf], + ) -> Result<(), Box> { + if let Some(diffs_dir) = Self::diffs_cache_dir() { + if diffs_dir.exists() { + for entry in std::fs::read_dir(diffs_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("diff") { + if !active_diff_files.contains(&path) { + let _ = std::fs::remove_file(path); + } + } + } + } + } + Ok(()) + } + pub fn load_state_cache(&mut self) -> Result<(), Box> { if !self.state_cache { return Ok(()); } let cache_path = Self::state_cache_path().ok_or("Cannot determine cache directory")?; - + if !cache_path.exists() { return Ok(()); } @@ -132,7 +198,8 @@ impl TextEditor { if !state_cache.tabs.is_empty() { self.tabs = state_cache.tabs.into_iter().map(Tab::from).collect(); - self.active_tab_index = std::cmp::min(state_cache.active_tab_index, self.tabs.len() - 1); + self.active_tab_index = + std::cmp::min(state_cache.active_tab_index, self.tabs.len() - 1); self.tab_counter = state_cache.tab_counter; self.text_needs_processing = true; } @@ -157,6 +224,13 @@ impl TextEditor { tab_counter: self.tab_counter, }; + let active_diff_files: Vec = state_cache + .tabs + .iter() + .filter_map(|tab| tab.diff_file.clone()) + .collect(); + let _ = Self::cleanup_orphaned_diffs(&active_diff_files); + let content = serde_json::to_string_pretty(&state_cache)?; std::fs::write(&cache_path, content)?; @@ -169,6 +243,13 @@ impl TextEditor { std::fs::remove_file(cache_path)?; } } + + if let Some(diffs_dir) = Self::diffs_cache_dir() { + if diffs_dir.exists() { + let _ = std::fs::remove_dir_all(diffs_dir); + } + } + Ok(()) } -} \ No newline at end of file +} diff --git a/src/app/state/tabs.rs b/src/app/state/tabs.rs index bfaba5e..21e2e30 100644 --- a/src/app/state/tabs.rs +++ b/src/app/state/tabs.rs @@ -18,8 +18,7 @@ impl TextEditor { self.update_find_matches(); } self.text_needs_processing = true; - - // Save state cache after adding new tab + if let Err(e) = self.save_state_cache() { eprintln!("Failed to save state cache: {e}"); } @@ -37,8 +36,7 @@ impl TextEditor { self.update_find_matches(); } self.text_needs_processing = true; - - // Save state cache after closing tab + if let Err(e) = self.save_state_cache() { eprintln!("Failed to save state cache: {e}"); } @@ -52,8 +50,7 @@ impl TextEditor { self.update_find_matches(); } self.text_needs_processing = true; - - // Save state cache after switching tabs + if let Err(e) = self.save_state_cache() { eprintln!("Failed to save state cache: {e}"); } diff --git a/src/app/state/ui.rs b/src/app/state/ui.rs index 1c003ac..c4f9a6b 100644 --- a/src/app/state/ui.rs +++ b/src/app/state/ui.rs @@ -23,7 +23,6 @@ impl TextEditor { } } - /// Get the configured font ID based on the editor's font settings pub fn get_font_id(&self) -> egui::FontId { let font_family = match self.font_family.as_str() { "Monospace" => egui::FontFamily::Monospace, @@ -32,13 +31,11 @@ impl TextEditor { egui::FontId::new(self.font_size, font_family) } - /// Immediately apply theme and save to configuration pub fn set_theme(&mut self, ctx: &egui::Context) { theme::apply(self.theme, ctx); self.save_config(); } - /// Apply font settings with immediate text reprocessing pub fn apply_font_settings(&mut self, ctx: &egui::Context) { let font_family = match self.font_family.as_str() { "Monospace" => egui::FontFamily::Monospace, @@ -56,21 +53,6 @@ impl TextEditor { self.save_config(); } - /// Apply font settings with immediate text reprocessing - pub fn apply_font_settings_with_ui(&mut self, ctx: &egui::Context, ui: &egui::Ui) { - self.apply_font_settings(ctx); - self.reprocess_text_for_font_change(ui); - self.font_settings_changed = false; - } - - /// Trigger immediate text reprocessing when font settings change - pub fn reprocess_text_for_font_change(&mut self, ui: &egui::Ui) { - if let Some(active_tab) = self.get_active_tab() { - self.process_text_for_rendering(&active_tab.content.to_string(), ui); - } - } - - /// Calculates the available width for the text editor, accounting for line numbers and separator pub fn calculate_editor_dimensions(&self, ui: &egui::Ui) -> EditorDimensions { let total_available_width = ui.available_width(); @@ -114,7 +96,6 @@ impl TextEditor { } } - /// Calculate the available width for non-word-wrapped content based on processed text data pub fn calculate_content_based_width(&self, ui: &egui::Ui) -> f32 { let processing_result = self.get_text_processing_result(); diff --git a/src/app/tab.rs b/src/app/tab.rs index df59360..8d85b12 100644 --- a/src/app/tab.rs +++ b/src/app/tab.rs @@ -34,7 +34,7 @@ impl Tab { let title = file_path .file_name() .and_then(|n| n.to_str()) - .unwrap_or("Untitled") + .unwrap_or("UNKNOWN") .to_string(); let hash = compute_content_hash(&content); diff --git a/src/app/theme.rs b/src/app/theme.rs index 7761053..7b1b271 100644 --- a/src/app/theme.rs +++ b/src/app/theme.rs @@ -1,4 +1,9 @@ use eframe::egui; +use plist::{Dictionary, Value}; +use std::collections::BTreeMap; +use syntect::highlighting::{ + Color as SyntectColor, Theme as SyntectTheme, ThemeSet, ThemeSettings, UnderlineOption, +}; #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize, Default)] pub enum Theme { @@ -194,3 +199,127 @@ fn detect_system_dark_mode() -> bool { true } } + +fn egui_color_to_syntect(color: egui::Color32) -> SyntectColor { + SyntectColor { + r: color.r(), + g: color.g(), + b: color.b(), + a: color.a(), + } +} + +pub fn create_code_theme_from_visuals(visuals: &egui::Visuals, font_size: f32) -> ThemeSet { + let text_color = visuals.override_text_color.unwrap_or(visuals.text_color()); + let bg_color = visuals.extreme_bg_color; + let selection_color = visuals.selection.bg_fill; + let comment_color = blend_colors(text_color, bg_color, 0.6); + let keyword_color = if visuals.dark_mode { + blend_colors(egui::Color32::from_rgb(100, 149, 237), text_color, 0.8) // CornflowerBlue-like + } else { + blend_colors(egui::Color32::from_rgb(0, 0, 139), text_color, 0.8) // DarkBlue-like + }; + let string_color = if visuals.dark_mode { + blend_colors(egui::Color32::from_rgb(144, 238, 144), text_color, 0.8) // LightGreen-like + } else { + blend_colors(egui::Color32::from_rgb(0, 128, 0), text_color, 0.8) // Green-like + }; + let number_color = if visuals.dark_mode { + blend_colors(egui::Color32::from_rgb(255, 165, 0), text_color, 0.8) // Orange-like + } else { + blend_colors(egui::Color32::from_rgb(165, 42, 42), text_color, 0.8) // Brown-like + }; + let function_color = if visuals.dark_mode { + blend_colors(egui::Color32::from_rgb(255, 20, 147), text_color, 0.8) // DeepPink-like + } else { + blend_colors(egui::Color32::from_rgb(128, 0, 128), text_color, 0.8) // Purple-like + }; + + let plist_theme = build_custom_theme_plist( + "System", + &format!("{:?}", bg_color), + &format!("{:?}", text_color), + &format!("{:?}", comment_color), + &format!("{:?}", string_color), + &format!("{:?}", keyword_color), + ); + let file = std::fs::File::create("system.tmTheme").unwrap(); + let writer = std::io::BufWriter::new(file); + + let _ = plist::to_writer_xml(writer, &plist_theme); + + let loaded_file = std::fs::File::open("system.tmTheme").unwrap(); + let mut loaded_reader = std::io::BufReader::new(loaded_file); + let loaded_theme = ThemeSet::load_from_reader(&mut loaded_reader).unwrap(); + let mut set = ThemeSet::new(); + set.add_from_folder(".").unwrap(); + return set; +} + +fn build_custom_theme_plist( + theme_name: &str, + background_color: &str, + foreground_color: &str, + comment_color: &str, + string_color: &str, + keyword_color: &str, +) -> Value { + let mut root_dict = Dictionary::new(); + root_dict.insert("name".to_string(), Value::String(theme_name.to_string())); + + let mut settings_array = Vec::new(); + + let mut global_settings_dict = Dictionary::new(); + let mut inner_global_settings = Dictionary::new(); + inner_global_settings.insert( + "background".to_string(), + Value::String(background_color.to_string()), + ); + inner_global_settings.insert( + "foreground".to_string(), + Value::String(foreground_color.to_string()), + ); + global_settings_dict.insert( + "settings".to_string(), + Value::Dictionary(inner_global_settings), + ); + settings_array.push(Value::Dictionary(global_settings_dict)); + + let mut comment_scope_dict = Dictionary::new(); + comment_scope_dict.insert("name".to_string(), Value::String("Comment".to_string())); + comment_scope_dict.insert("scope".to_string(), Value::String("comment".to_string())); + let mut comment_settings = Dictionary::new(); + comment_settings.insert( + "foreground".to_string(), + Value::String(comment_color.to_string()), + ); + comment_settings.insert("fontStyle".to_string(), Value::String("italic".to_string())); + comment_scope_dict.insert("settings".to_string(), Value::Dictionary(comment_settings)); + settings_array.push(Value::Dictionary(comment_scope_dict)); + + let mut string_scope_dict = Dictionary::new(); + string_scope_dict.insert("name".to_string(), Value::String("String".to_string())); + string_scope_dict.insert("scope".to_string(), Value::String("string".to_string())); + let mut string_settings = Dictionary::new(); + string_settings.insert( + "foreground".to_string(), + Value::String(string_color.to_string()), + ); + string_scope_dict.insert("settings".to_string(), Value::Dictionary(string_settings)); + settings_array.push(Value::Dictionary(string_scope_dict)); + + let mut keyword_scope_dict = Dictionary::new(); + keyword_scope_dict.insert("name".to_string(), Value::String("Keyword".to_string())); + keyword_scope_dict.insert("scope".to_string(), Value::String("keyword".to_string())); + let mut keyword_settings = Dictionary::new(); + keyword_settings.insert( + "foreground".to_string(), + Value::String(keyword_color.to_string()), + ); + keyword_scope_dict.insert("settings".to_string(), Value::Dictionary(keyword_settings)); + settings_array.push(Value::Dictionary(keyword_scope_dict)); + + root_dict.insert("settings".to_string(), Value::Array(settings_array)); + + Value::Dictionary(root_dict) +} diff --git a/src/io.rs b/src/io.rs index d2ec674..ac0dd74 100644 --- a/src/io.rs +++ b/src/io.rs @@ -7,6 +7,116 @@ pub(crate) fn new_file(app: &mut TextEditor) { app.add_new_tab(); } +fn is_text_file(path: &PathBuf) -> bool { + if let Some(extension) = path.extension().and_then(|s| s.to_str()) { + matches!( + extension.to_lowercase().as_str(), + "txt" + | "md" + | "markdown" + | "rs" + | "py" + | "js" + | "ts" + | "tsx" + | "jsx" + | "c" + | "cpp" + | "cc" + | "cxx" + | "h" + | "hpp" + | "java" + | "go" + | "php" + | "rb" + | "cs" + | "swift" + | "kt" + | "scala" + | "sh" + | "bash" + | "zsh" + | "fish" + | "html" + | "htm" + | "xml" + | "css" + | "scss" + | "sass" + | "json" + | "yaml" + | "yml" + | "toml" + | "sql" + | "lua" + | "vim" + | "dockerfile" + | "makefile" + | "gitignore" + | "conf" + | "cfg" + | "ini" + | "log" + | "csv" + | "tsv" + ) + } else { + // Files without extensions might be text files, but let's be conservative + // and only include them if they're small and readable + if let Ok(metadata) = fs::metadata(path) { + metadata.len() < 1024 * 1024 // Only consider files smaller than 1MB + } else { + false + } + } +} + +pub(crate) fn open_files_from_directory( + app: &mut TextEditor, + dir_path: PathBuf, +) -> Result { + if !dir_path.is_dir() { + return Err(format!("{} is not a directory", dir_path.display())); + } + + let entries = fs::read_dir(&dir_path) + .map_err(|e| format!("Failed to read directory {}: {}", dir_path.display(), e))?; + + let mut opened_count = 0; + let mut text_files: Vec = Vec::new(); + + // Collect all text files in the directory + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; + let path = entry.path(); + + if path.is_file() && is_text_file(&path) { + text_files.push(path); + } + } + + // Sort files by name for consistent ordering + text_files.sort(); + + // Open each text file + for file_path in text_files { + match open_file_from_path(app, file_path.clone()) { + Ok(()) => opened_count += 1, + Err(e) => eprintln!("Warning: {}", e), + } + } + + if opened_count == 0 { + Err(format!( + "No text files found in directory {}", + dir_path.display() + )) + } else { + Ok(opened_count) + } +} + pub(crate) fn open_file(app: &mut TextEditor) { if let Some(path) = rfd::FileDialog::new() .add_filter("Text files", &["*"]) @@ -43,7 +153,7 @@ pub(crate) fn open_file(app: &mut TextEditor) { if app.show_find && !app.find_query.is_empty() { app.update_find_matches(); } - + if let Err(e) = app.save_state_cache() { eprintln!("Failed to save state cache: {e}"); } @@ -55,6 +165,50 @@ pub(crate) fn open_file(app: &mut TextEditor) { } } +pub(crate) fn open_file_from_path(app: &mut TextEditor, path: PathBuf) -> Result<(), String> { + match fs::read_to_string(&path) { + Ok(content) => { + 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 { + if let Some(active_tab) = app.get_active_tab_mut() { + let title = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Untitled"); + active_tab.content = content; + active_tab.file_path = Some(path.to_path_buf()); + active_tab.title = title.to_string(); + active_tab.mark_as_saved(); + } + app.text_needs_processing = true; + } else { + let new_tab = Tab::new_with_file(content, path); + app.tabs.push(new_tab); + app.active_tab_index = app.tabs.len() - 1; + app.text_needs_processing = true; + } + + if app.show_find && !app.find_query.is_empty() { + app.update_find_matches(); + } + + if let Err(e) = app.save_state_cache() { + eprintln!("Failed to save state cache: {e}"); + } + + Ok(()) + } + Err(err) => Err(format!("Failed to open file {}: {}", path.display(), 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 { @@ -85,7 +239,7 @@ pub(crate) fn save_to_path(app: &mut TextEditor, path: PathBuf) { active_tab.file_path = Some(path.to_path_buf()); active_tab.title = title.to_string(); active_tab.mark_as_saved(); - + if let Err(e) = app.save_state_cache() { eprintln!("Failed to save state cache: {e}"); } diff --git a/src/main.rs b/src/main.rs index 3621bd3..de06732 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,9 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use eframe::egui; +use std::env; +use std::io::IsTerminal; +use std::path::PathBuf; mod app; mod io; @@ -8,6 +11,14 @@ mod ui; use app::{config::Config, TextEditor}; fn main() -> eframe::Result { + let args: Vec = env::args().collect(); + + let initial_paths: Vec = args.iter().skip(1).map(|arg| PathBuf::from(arg)).collect(); + + if std::io::stdin().is_terminal() { + println!("This is a GUI application, are you sure you want to launch from terminal?"); + } + let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_min_inner_size([600.0, 400.0]) @@ -21,6 +32,12 @@ fn main() -> eframe::Result { eframe::run_native( "ced", options, - Box::new(move |cc| Ok(Box::new(TextEditor::from_config_with_context(config, cc)))), + Box::new(move |cc| { + Ok(Box::new(TextEditor::from_config_with_context( + config, + cc, + initial_paths, + ))) + }), ) } diff --git a/src/ui.rs b/src/ui.rs index 35cb22e..dd29176 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,5 +1,6 @@ pub(crate) mod about_window; pub(crate) mod central_panel; +pub(crate) mod constants; pub(crate) mod find_window; pub(crate) mod menu_bar; pub(crate) mod preferences_window; diff --git a/src/ui/about_window.rs b/src/ui/about_window.rs index 18f2008..5c4f40a 100644 --- a/src/ui/about_window.rs +++ b/src/ui/about_window.rs @@ -1,4 +1,5 @@ use crate::app::TextEditor; +use crate::ui::constants::*; use eframe::egui; pub(crate) fn about_window(app: &mut TextEditor, ctx: &egui::Context) { @@ -16,20 +17,20 @@ pub(crate) fn about_window(app: &mut TextEditor, ctx: &egui::Context) { .frame(egui::Frame { fill: visuals.window_fill, stroke: visuals.window_stroke, - corner_radius: egui::CornerRadius::same(8), + corner_radius: egui::CornerRadius::same(CORNER_RADIUS), shadow: visuals.window_shadow, - inner_margin: egui::Margin::same(16), + inner_margin: egui::Margin::same(INNER_MARGIN), 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) + .size(UI_TEXT_SIZE) .weak(), ); - ui.add_space(12.0); + ui.add_space(LARGE); let visuals = ui.visuals(); let close_button = egui::Button::new("Close") .fill(visuals.widgets.inactive.bg_fill) diff --git a/src/ui/central_panel.rs b/src/ui/central_panel.rs index 5ebab4e..187874c 100644 --- a/src/ui/central_panel.rs +++ b/src/ui/central_panel.rs @@ -1,8 +1,10 @@ mod editor; mod find_highlight; +mod languages; mod line_numbers; use crate::app::TextEditor; +use crate::ui::constants::*; use eframe::egui; use egui::UiKind; @@ -73,13 +75,13 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { }; let separator_widget = |ui: &mut egui::Ui| { - ui.add_space(3.0); + ui.add_space(SMALL); 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(4.0); + ui.add_space(SMALL); }; egui::ScrollArea::vertical() diff --git a/src/ui/central_panel/editor.rs b/src/ui/central_panel/editor.rs index 7297415..26784ed 100644 --- a/src/ui/central_panel/editor.rs +++ b/src/ui/central_panel/editor.rs @@ -1,5 +1,6 @@ use crate::app::TextEditor; use eframe::egui; +use egui_extras::syntax_highlighting::{self}; use super::find_highlight; @@ -12,6 +13,8 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R let show_shortcuts = app.show_shortcuts; let word_wrap = app.word_wrap; let font_size = app.font_size; + let font_id = app.get_font_id(); + let syntax_highlighting_enabled = app.syntax_highlighting; let reset_zoom_key = egui::Id::new("editor_reset_zoom"); let should_reset_zoom = ui @@ -96,14 +99,39 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R f32::INFINITY }; + let language = super::languages::get_language_from_extension(active_tab.file_path.as_deref()); + let mut layouter = |ui: &egui::Ui, string: &dyn egui::TextBuffer, wrap_width: f32| { + // let syntect_theme = + // crate::app::theme::create_code_theme_from_visuals(ui.visuals(), font_size); + let theme = egui_extras::syntax_highlighting::CodeTheme::from_style(ui.style()); + let text = string.as_str(); + let mut layout_job = if syntax_highlighting_enabled && language != "txt" { + // let mut settings = egui_extras::syntax_highlighting::SyntectSettings::default(); + // settings.ts = syntect_theme; + // syntax_highlighting::highlight_with(ui.ctx(), &ui.style().clone(), &theme, text, &language, &settings) + syntax_highlighting::highlight(ui.ctx(), &ui.style().clone(), &theme, text, &language) + } else { + syntax_highlighting::highlight(ui.ctx(), &ui.style().clone(), &theme, text, "") + }; + + if syntax_highlighting_enabled && language != "txt" { + for section in &mut layout_job.sections { + section.format.font_id = font_id.clone(); + } + } + + layout_job.wrap.max_width = wrap_width; + ui.fonts(|f| f.layout_job(layout_job)) + }; + 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(!show_find) .cursor_at_end(false) + .layouter(&mut layouter) .id(egui::Id::new("main_text_editor")); let output = if word_wrap { @@ -130,7 +158,6 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R None }; - // Save state cache when content changes (after releasing the borrow) if content_changed { if let Err(e) = app.save_state_cache() { eprintln!("Failed to save state cache: {e}"); @@ -169,7 +196,6 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R app.previous_content = content.to_owned(); app.previous_cursor_char_index = current_cursor_pos; - } if app.font_settings_changed || app.text_needs_processing { diff --git a/src/ui/central_panel/languages.rs b/src/ui/central_panel/languages.rs new file mode 100644 index 0000000..d95574a --- /dev/null +++ b/src/ui/central_panel/languages.rs @@ -0,0 +1,55 @@ +pub fn get_language_from_extension(file_path: Option<&std::path::Path>) -> String { + let default_lang = "txt".to_string(); + + let path = match file_path { + Some(p) => p, + None => return default_lang, + }; + + if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) { + match extension.to_lowercase().as_str() { + "rs" => "rs".to_string(), + "py" => "py".to_string(), + "js" => "js".to_string(), + "ts" => "ts".to_string(), + "tsx" => "tsx".to_string(), + "jsx" => "jsx".to_string(), + "c" => "c".to_string(), + "cpp" | "cc" | "cxx" => "cpp".to_string(), + "h" | "hpp" => "cpp".to_string(), + "java" => "java".to_string(), + "go" => "go".to_string(), + "php" => "php".to_string(), + "rb" => "rb".to_string(), + "cs" => "cs".to_string(), + "swift" => "swift".to_string(), + "kt" => "kt".to_string(), + "scala" => "scala".to_string(), + "sh" | "bash" | "zsh" | "fish" => "sh".to_string(), + "html" | "htm" => "html".to_string(), + "xml" => "xml".to_string(), + "css" => "css".to_string(), + "scss" | "sass" => "scss".to_string(), + "json" => "json".to_string(), + "yaml" | "yml" => "yaml".to_string(), + "toml" => "toml".to_string(), + "md" | "markdown" => "md".to_string(), + "sql" => "sql".to_string(), + "lua" => "lua".to_string(), + "vim" => "vim".to_string(), + "dockerfile" => "dockerfile".to_string(), + "makefile" => "makefile".to_string(), + _ => default_lang, + } + } else if let Some(filename) = path.file_name().and_then(|name| name.to_str()) { + match filename.to_lowercase().as_str() { + "dockerfile" => "dockerfile".to_string(), + "makefile" => "makefile".to_string(), + "cargo.toml" | "pyproject.toml" => "toml".to_string(), + "package.json" => "json".to_string(), + _ => default_lang, + } + } else { + default_lang + } +} diff --git a/src/ui/constants.rs b/src/ui/constants.rs new file mode 100644 index 0000000..e980900 --- /dev/null +++ b/src/ui/constants.rs @@ -0,0 +1,26 @@ +pub const SMALL: f32 = 4.0; +pub const MEDIUM: f32 = 8.0; +pub const LARGE: f32 = 12.0; +pub const VLARGE: f32 = 16.0; + +pub const UI_HEADER_SIZE: f32 = 18.0; +pub const UI_TEXT_SIZE: f32 = 14.0; + +pub const MIN_FONT_SIZE: f32 = 8.0; +pub const MAX_FONT_SIZE: f32 = 32.0; + +pub const WINDOW_WIDTH_RATIO: f32 = 0.6; +pub const WINDOW_HEIGHT_RATIO: f32 = 0.7; +pub const WINDOW_MIN_WIDTH: f32 = 300.0; +pub const WINDOW_MAX_WIDTH: f32 = 400.0; +pub const WINDOW_MIN_HEIGHT: f32 = 250.0; +pub const WINDOW_MAX_HEIGHT: f32 = 500.0; + +pub const CORNER_RADIUS: u8 = 8; + +pub const FONT_SIZE_INPUT_WIDTH: f32 = 24.0; +pub const DEFAULT_FONT_SIZE_STR: &str = "14"; + +pub const PREVIEW_AREA_MAX_HEIGHT: f32 = 150.0; + +pub const INNER_MARGIN: i8 = 8; diff --git a/src/ui/find_window.rs b/src/ui/find_window.rs index 7fed489..446e5cf 100644 --- a/src/ui/find_window.rs +++ b/src/ui/find_window.rs @@ -1,4 +1,5 @@ use crate::app::TextEditor; +use crate::ui::constants::*; use eframe::egui; pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) { @@ -37,9 +38,9 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) { .frame(egui::Frame { fill: visuals.window_fill, stroke: visuals.window_stroke, - corner_radius: egui::CornerRadius::same(8), + corner_radius: egui::CornerRadius::same(CORNER_RADIUS), shadow: visuals.window_shadow, - inner_margin: egui::Margin::same(16), + inner_margin: egui::Margin::same(INNER_MARGIN), outer_margin: egui::Margin::same(0), }) .show(ctx, |ui| { @@ -78,7 +79,7 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) { if app.show_replace_section { ui.horizontal(|ui| { - ui.add_space(4.0); + ui.add_space(SMALL); ui.label("Replace:"); let _replace_response = ui.add( egui::TextEdit::singleline(&mut app.replace_query) @@ -88,7 +89,7 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) { }); } - ui.add_space(8.0); + ui.add_space(MEDIUM); ui.horizontal(|ui| { let case_sensitive_changed = ui @@ -98,7 +99,7 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) { query_changed = true; } if app.show_replace_section { - ui.add_space(8.0); + ui.add_space(MEDIUM); let replace_current_enabled = !app.find_matches.is_empty() && app.current_match_index.is_some(); @@ -117,7 +118,7 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) { } }); - ui.add_space(8.0); + ui.add_space(MEDIUM); ui.horizontal(|ui| { let match_text = if app.find_matches.is_empty() { @@ -139,7 +140,7 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) { should_close = true; } - ui.add_space(4.0); + ui.add_space(SMALL); let next_enabled = !app.find_matches.is_empty(); ui.add_enabled_ui(next_enabled, |ui| { diff --git a/src/ui/menu_bar.rs b/src/ui/menu_bar.rs index 268840e..d37da87 100644 --- a/src/ui/menu_bar.rs +++ b/src/ui/menu_bar.rs @@ -185,6 +185,13 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { app.save_config(); ui.close_kind(UiKind::Menu); } + if ui + .checkbox(&mut app.syntax_highlighting, "Syntax Highlighting") + .clicked() + { + app.save_config(); + ui.close_kind(UiKind::Menu); + } if ui.checkbox(&mut app.word_wrap, "Word Wrap").clicked() { app.save_config(); ui.close_kind(UiKind::Menu); @@ -276,21 +283,15 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { if app.hide_tab_bar { let tab_title = if let Some(tab) = app.get_active_tab() { - tab.title.to_owned() + tab.get_display_title() } else { let empty_tab = crate::app::tab::Tab::new_empty(1); - empty_tab.title.to_owned() + empty_tab.get_display_title() }; let window_width = ctx.screen_rect().width(); let font_id = ui.style().text_styles[&egui::TextStyle::Body].to_owned(); - let tab_title = if app.get_active_tab().is_some_and(|tab| tab.is_modified) { - format!("{tab_title}*") - } else { - tab_title - }; - let text_galley = ui.fonts(|fonts| { fonts.layout_job(egui::text::LayoutJob::simple_singleline( tab_title, diff --git a/src/ui/preferences_window.rs b/src/ui/preferences_window.rs index 9576d82..9f40ed6 100644 --- a/src/ui/preferences_window.rs +++ b/src/ui/preferences_window.rs @@ -1,11 +1,14 @@ use crate::app::TextEditor; +use crate::ui::constants::*; 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).clamp(300.0, 400.0); - let window_height = (screen_rect.height() * 0.7).clamp(250.0, 500.0); + let window_width = + (screen_rect.width() * WINDOW_WIDTH_RATIO).clamp(WINDOW_MIN_WIDTH, WINDOW_MAX_WIDTH); + let window_height = + (screen_rect.height() * WINDOW_HEIGHT_RATIO).clamp(WINDOW_MIN_HEIGHT, WINDOW_MAX_HEIGHT); let max_size = egui::Vec2::new(window_width, window_height); egui::Window::new("Preferences") @@ -19,174 +22,168 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { .frame(egui::Frame { fill: visuals.window_fill, stroke: visuals.window_stroke, - corner_radius: egui::CornerRadius::same(8), + corner_radius: egui::CornerRadius::same(CORNER_RADIUS), shadow: visuals.window_shadow, - inner_margin: egui::Margin::same(16), + inner_margin: egui::Margin::same(INNER_MARGIN), outer_margin: egui::Margin::same(0), }) .show(ctx, |ui| { ui.vertical_centered(|ui| { - - ui.heading("General Settings"); - ui.add_space(8.0); + ui.heading("Editor Settings"); + ui.add_space(MEDIUM); ui.horizontal(|ui| { - if ui - .checkbox(&mut app.state_cache, "State Cache") - .on_hover_text( - "Save and restore open tabs and unsaved changes between sessions" - ) - .changed() - { - app.save_config(); - if !app.state_cache { - if let Err(e) = TextEditor::clear_state_cache() { - eprintln!("Failed to clear state cache: {e}"); + ui.vertical(|ui| { + if ui + .checkbox(&mut app.state_cache, "Maintain State") + .on_hover_text("Unsaved changes will be cached between sessions") + .changed() + { + app.save_config(); + if !app.state_cache { + if let Err(e) = TextEditor::clear_state_cache() { + eprintln!("Failed to clear state cache: {e}"); + } } } - } + ui.add_space(SMALL); + if ui + .checkbox(&mut app.show_line_numbers, "Show Line Numbers") + .changed() + { + app.save_config(); + } + ui.add_space(SMALL); + if ui + .checkbox(&mut app.auto_hide_toolbar, "Auto Hide Toolbar") + .on_hover_text( + "Hide the top bar until you move your mouse to the upper edge", + ) + .changed() + { + app.save_config(); + } + }); + ui.vertical(|ui| { + if ui.checkbox(&mut app.word_wrap, "Word Wrap").changed() { + app.save_config(); + } + ui.add_space(SMALL); + if ui + .checkbox(&mut app.syntax_highlighting, "Syntax Highlighting") + .changed() + { + app.save_config(); + } + ui.add_space(SMALL); + if ui + .checkbox(&mut app.hide_tab_bar, "Hide Tab Bar") + .on_hover_text( + "Hide the tab bar and show tab title in menu bar instead", + ) + .changed() + { + app.save_config(); + } + }); }); - ui.add_space(4.0); - - ui.horizontal(|ui| { - if ui - .checkbox(&mut app.show_line_numbers, "Show Line Numbers") - .changed() - { - app.save_config(); - } - }); - - ui.add_space(4.0); - - ui.horizontal(|ui| { - if ui - .checkbox(&mut app.word_wrap, "Word Wrap") - .changed() - { - app.save_config(); - } - }); - - ui.add_space(4.0); - - ui.horizontal(|ui| { - if ui - .checkbox(&mut app.auto_hide_toolbar, "Auto Hide Toolbar") - .on_hover_text("Hide the menu bar until you move your mouse to the top") - .changed() - { - app.save_config(); - } - }); - - ui.add_space(4.0); - - ui.horizontal(|ui| { - if ui - .checkbox(&mut app.hide_tab_bar, "Hide Tab Bar") - .on_hover_text("Hide the tab bar and show tab title in menu bar instead") - .changed() - { - app.save_config(); - } - }); - - ui.add_space(12.0); + ui.add_space(SMALL); ui.separator(); + ui.add_space(LARGE); ui.heading("Font Settings"); - ui.add_space(8.0); + ui.add_space(MEDIUM); ui.horizontal(|ui| { - ui.label("Font Family:"); - ui.add_space(5.0); + ui.vertical(|ui| { + ui.label("Font Family:"); + ui.add_space(SMALL); + ui.label("Font Size:"); + }); - 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; + ui.vertical(|ui| { + 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 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_or(&DEFAULT_FONT_SIZE_STR.to_string()) + .to_owned(); + ui.add_space(SMALL); + ui.horizontal(|ui| { + let response = ui.add( + egui::TextEdit::singleline(&mut font_size_text) + .desired_width(FONT_SIZE_INPUT_WIDTH) + .hint_text(DEFAULT_FONT_SIZE_STR) + .id(egui::Id::new("font_size_input")), + ); + + app.font_size_input = Some(font_size_text.to_owned()); + + if response.clicked() { + response.request_focus(); } - if ui - .selectable_value( - &mut app.font_family, - "Monospace".to_string(), - "Monospace", - ) - .clicked() - { - changed = true; + + ui.label("px"); + + if response.lost_focus() { + if let Ok(new_size) = font_size_text.parse::() { + let clamped_size = new_size.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE); + if (app.font_size - clamped_size).abs() > 0.1 { + app.font_size = clamped_size; + app.apply_font_settings(ctx); + } + } + app.font_size_input = None; } - }); - 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_or(&"14".to_string()) - .to_owned(); - 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.to_owned()); - - 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; + if changed { app.apply_font_settings(ctx); } - } - app.font_size_input = None; - } + }) + }); }); - ui.add_space(8.0); + ui.add_space(MEDIUM); ui.label("Preview:"); - ui.add_space(4.0); + ui.add_space(SMALL); egui::ScrollArea::vertical() - .max_height(150.0) + .max_height(PREVIEW_AREA_MAX_HEIGHT) .show(ui, |ui| { egui::Frame::new() .fill(visuals.code_bg_color) .stroke(visuals.widgets.noninteractive.bg_stroke) - .inner_margin(egui::Margin::same(8)) + .inner_margin(egui::Margin::same(INNER_MARGIN)) .show(ui, |ui| { let preview_font = egui::FontId::new( app.font_size, @@ -216,7 +213,7 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { }); }); - ui.add_space(12.0); + ui.add_space(LARGE); if ui.button("Close").clicked() { app.show_preferences = false; diff --git a/src/ui/shortcuts_window.rs b/src/ui/shortcuts_window.rs index a3ca874..560baf3 100644 --- a/src/ui/shortcuts_window.rs +++ b/src/ui/shortcuts_window.rs @@ -1,39 +1,48 @@ use crate::app::TextEditor; +use crate::ui::constants::*; use eframe::egui; fn render_shortcuts_content(ui: &mut egui::Ui) { ui.vertical_centered(|ui| { - ui.label(egui::RichText::new("Navigation").size(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.label( + egui::RichText::new("Navigation") + .size(UI_HEADER_SIZE) + .strong(), + ); + ui.label(egui::RichText::new("Ctrl + N: New").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + O: Open").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + S: Save").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + Shift + S: Save As").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + T: New Tab").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + W: Close Tab").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + Tab: Next Tab").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + Shift + Tab: Last Tab").size(UI_TEXT_SIZE)); + ui.add_space(VLARGE); ui.separator(); - 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.label(egui::RichText::new("Ctrl + F: Find").size(14.0)); + ui.label(egui::RichText::new("Editing").size(UI_HEADER_SIZE).strong()); + ui.label(egui::RichText::new("Ctrl + Z: Undo").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + Shift + Z: Redo").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + X: Cut").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + C: Copy").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + V: Paste").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + A: Select All").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + D: Delete Line").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + F: Find").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + R: Replace").size(UI_TEXT_SIZE)); - ui.add_space(16.0); + ui.add_space(VLARGE); 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("Views").size(UI_HEADER_SIZE).strong()); + ui.label(egui::RichText::new("Ctrl + L: Toggle Line Numbers").size(UI_TEXT_SIZE)); + ui.label( + egui::RichText::new("Ctrl + Shift + L: Change Line Number Side").size(UI_TEXT_SIZE), + ); + ui.label(egui::RichText::new("Ctrl + K: Toggle Word Wrap").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + H: Toggle Auto Hide Toolbar").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + P: Preferences").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + =/-: Increase/Decrease Font Size").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + Shift + =/-: Zoom In/Out").size(UI_TEXT_SIZE)); // ui.label( // egui::RichText::new("Ctrl + Shift + .: Toggle Vim Mode") // .size(14.0) @@ -42,7 +51,7 @@ fn render_shortcuts_content(ui: &mut egui::Ui) { // egui::RichText::new("Ctrl + .: Toggle Vim Mode") // .size(14.0) // ); - ui.add_space(16.0); + ui.add_space(VLARGE); ui.separator(); }); } @@ -51,8 +60,10 @@ pub(crate) fn shortcuts_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).clamp(300.0, 400.0); - let window_height = (screen_rect.height() * 0.7).clamp(250.0, 500.0); + let window_width = + (screen_rect.width() * WINDOW_WIDTH_RATIO).clamp(WINDOW_MIN_WIDTH, WINDOW_MAX_WIDTH); + let window_height = + (screen_rect.height() * WINDOW_HEIGHT_RATIO).clamp(WINDOW_MIN_HEIGHT, WINDOW_MAX_HEIGHT); egui::Window::new("Shortcuts") .collapsible(false) @@ -64,9 +75,9 @@ pub(crate) fn shortcuts_window(app: &mut TextEditor, ctx: &egui::Context) { .frame(egui::Frame { fill: visuals.window_fill, stroke: visuals.window_stroke, - corner_radius: egui::CornerRadius::same(8), + corner_radius: egui::CornerRadius::same(CORNER_RADIUS), shadow: visuals.window_shadow, - inner_margin: egui::Margin::same(16), + inner_margin: egui::Margin::same(INNER_MARGIN), outer_margin: egui::Margin::same(0), }) .show(ctx, |ui| { @@ -85,7 +96,7 @@ pub(crate) fn shortcuts_window(app: &mut TextEditor, ctx: &egui::Context) { ); ui.vertical_centered(|ui| { - ui.add_space(8.0); + ui.add_space(MEDIUM); let visuals = ui.visuals(); let close_button = egui::Button::new("Close") .fill(visuals.widgets.inactive.bg_fill) diff --git a/src/ui/tab_bar.rs b/src/ui/tab_bar.rs index d09b297..84f9583 100644 --- a/src/ui/tab_bar.rs +++ b/src/ui/tab_bar.rs @@ -3,86 +3,98 @@ 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") + let tab_bar = 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; + egui::ScrollArea::horizontal() + .auto_shrink([false, true]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .scroll_source(egui::scroll_area::ScrollSource::DRAG) + .show(ui, |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_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(); + 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; + 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()) - }; + 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(); - } + 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); - } + let tab_response = ui.add( + egui::Label::new(label_text) + .selectable(false) + .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(); + } - if tabs_len > 1 { let visuals = ui.visuals(); - let close_button = egui::Button::new("×") + let add_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); - } + if ui.add(add_button).clicked() { + add_new_tab = true; } - } - 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(); - } - }); + 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); + app.tab_bar_rect = Some(tab_bar.response.rect); }