Compare commits

...

26 Commits

Author SHA1 Message Date
9ba260f685 Merge pull request 'master' (#7) from master into release
Reviewed-on: #7
2025-07-28 21:24:46 +00:00
cce83bb0cf nicer readme explanation 2025-07-28 17:22:48 -04:00
5b1634825e Merge pull request 'colors' (#6) from colors into master
Reviewed-on: #6
2025-07-26 15:58:05 +00:00
eaefb76ce7 formatting 2025-07-26 11:52:10 -04:00
0c7ae2d1b1 better state caching, started building custom syntax theme 2025-07-26 11:50:48 -04:00
e5b1214f63 formatting fixes 2025-07-23 13:17:10 -04:00
5dc0b6d638 file diffs are kept separate 2025-07-23 13:14:04 -04:00
fd489fb156 Merge branch 'master' into colors 2025-07-23 12:50:04 -04:00
51063aac44 added state caching 2025-07-23 12:47:26 -04:00
4651d7caf4 checkpoint for theming 2025-07-23 11:46:54 -04:00
fd26344b5f moving lanugage/theme stuff out 2025-07-22 23:09:01 -04:00
c50c9b6779 syntax highlighting toggle, uses your font now 2025-07-22 22:49:54 -04:00
e4091c0c3f Merge branch 'master' into colors 2025-07-22 22:27:23 -04:00
c4de8acec5 can click and drag tab bar now, fix hashing, unification 2025-07-22 22:26:48 -04:00
0da68c8801 cut down on overhead 2025-07-22 21:42:31 -04:00
67e9331535 syntax highlighting does kinda work but its slow 2025-07-21 20:28:09 -04:00
a3cc8b96f0 Merge pull request 'Find and Replace' (#5) from master into release
Reviewed-on: #5
2025-07-16 21:36:22 +00:00
f56ad6c7c5 formatting/some sanity fixes 2025-07-16 17:34:11 -04:00
1edf0995c0 no more clones 2025-07-16 17:20:09 -04:00
6fa0aa0b61 same thing for find 2025-07-16 16:04:14 -04:00
48cfcb9997 slicing more safely now 2025-07-16 15:28:55 -04:00
325252e96b updated cargo.toml 2025-07-16 13:29:31 -04:00
77eba47f9d Updated dependencies, even better and smarter scrolling, find and replace functionality 2025-07-16 13:27:31 -04:00
b313082374 better tab config name 2025-07-15 20:08:38 -04:00
c1d0c7af1e config checking/merging, slightly more generous scroll width for giga fast typing 2025-07-15 13:05:32 -04:00
3ee73c3d9b Merge pull request 'Merge pull request 'Update to 0.0.4' (#1) from master into release' (#3) from release into master
Reviewed-on: #3
2025-07-15 17:03:52 +00:00
32 changed files with 1841 additions and 599 deletions

View File

@ -1,13 +1,19 @@
[package] [package]
name = "ced" name = "ced"
version = "0.0.4" version = "0.1.3"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
eframe = "0.31" eframe = "0.32"
egui = "0.31" egui = "0.32"
serde = { version = "1.0", features = ["derive"] } egui_extras = { version = "0.32", features = ["syntect"] }
rfd = "0.15" serde = { version = "1.0.219", features = ["derive"] }
toml = "0.8" serde_json = "1.0.141"
dirs = "5.0" rfd = "0.15.4"
libc = "0.2" 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"] }

View File

@ -9,7 +9,7 @@ There is a disturbing lack of simple GUI text editors available on Linux nativel
## Features ## Features
* Sane text editing with standard keybindings (`Ctrl+A`, `Ctrl+C`, etc.). * Sane text editing with standard keybindings (`Ctrl+A`, `Ctrl+C`, etc.).
* Opens with a blank slate for quick typing, remember Notepad? * Choose between a fresh start each time you open, or maintaining a consistent state.
* Separate UI zoom that doesn't affect font size (`Ctrl+Shift` + `+`/`-`). * Separate UI zoom that doesn't affect font size (`Ctrl+Shift` + `+`/`-`).
* Ricers rejoice, your `pywal` colors will be used! * Ricers rejoice, your `pywal` colors will be used!
* Weirdly smooth typing experience. * Weirdly smooth typing experience.
@ -39,6 +39,7 @@ sudo install -Dm644 ced.desktop /usr/share/applications/ced.desktop
Here is an example `config.toml`: Here is an example `config.toml`:
```toml ```toml
state_cache = true
auto_hide_toolbar = false auto_hide_toolbar = false
show_line_numbers = false show_line_numbers = false
word_wrap = false word_wrap = false
@ -46,14 +47,18 @@ theme = "System"
line_side = false line_side = false
font_family = "Monospace" font_family = "Monospace"
font_size = 16.0 font_size = 16.0
syntax_highlighting = true
``` ```
### Options ### Options
| Option | Default | Description | | Option | Default | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `state_cache` | `false` | If `true`, opened files will remain opened with their unsaved changes when running the application again. |
| `auto_hide_toolbar` | `false` | If `true`, the menu bar at the top will be hidden. Move your mouse to the top of the window to reveal it. | | `auto_hide_toolbar` | `false` | If `true`, the menu bar at the top will be hidden. Move your mouse to the top of the window to reveal it. |
| `hide_tab_bar` | 'true' | If `false`, a separate tab bar will be drawn below the toolbar. |
| `show_line_numbers` | `false` | If `true`, line numbers will be displayed on the side specified by `line_side`. | | `show_line_numbers` | `false` | If `true`, line numbers will be displayed on the side specified by `line_side`. |
| `syntax_highlighting` | `false` | If `true`, text will be highlighted based on detected language. |
| `line_side` | `false` | If `false`, line numbers are on the left. If `true`, they are on the right. | | `line_side` | `false` | If `false`, line numbers are on the left. If `true`, they are on the right. |
| `word_wrap` | `false` | If `true`, lines will wrap when they reach the edge of the window. | | `word_wrap` | `false` | If `true`, lines will wrap when they reach the edge of the window. |
| `font_family` | `"Proportional"` | The font family used for the editor text. | | `font_family` | `"Proportional"` | The font family used for the editor text. |
@ -64,9 +69,7 @@ font_size = 16.0
In order of importance. In order of importance.
| Feature | Info | | Feature | Info |
| ------- | ---- | | ------- | ---- |
| **Find/Replace:** | In progress. | | **LSP:** | Looking at allowing you to use/attach your own tools for this. |
| **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. |
| **Choose Font** | More than just Monospace/Proportional. | | **Choose Font** | More than just Monospace/Proportional. |
| **Vim Mode:** | It's in-escapable. | | **Vim Mode:** | It's in-escapable. |
| **CLI Mode:** | 💀 | | **CLI Mode:** | 💀 |

View File

@ -5,28 +5,71 @@ use super::theme::Theme;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config { pub struct Config {
#[serde(default = "default_state_cache")]
pub state_cache: bool,
#[serde(default = "default_auto_hide_toolbar")]
pub auto_hide_toolbar: bool, pub auto_hide_toolbar: bool,
pub auto_hide_tab_bar: bool, #[serde(default = "default_hide_tab_bar")]
pub hide_tab_bar: bool,
#[serde(default = "default_show_line_numbers")]
pub show_line_numbers: bool, pub show_line_numbers: bool,
#[serde(default = "default_word_wrap")]
pub word_wrap: bool, pub word_wrap: bool,
#[serde(default = "Theme::default")]
pub theme: Theme, pub theme: Theme,
#[serde(default = "default_line_side")]
pub line_side: bool, pub line_side: bool,
#[serde(default = "default_font_family")]
pub font_family: String, pub font_family: String,
#[serde(default = "default_font_size")]
pub font_size: f32, pub font_size: f32,
#[serde(default = "default_syntax_highlighting")]
pub syntax_highlighting: bool,
// pub vim_mode: bool, // pub vim_mode: bool,
} }
fn default_state_cache() -> bool {
false
}
fn default_auto_hide_toolbar() -> bool {
false
}
fn default_hide_tab_bar() -> bool {
true
}
fn default_show_line_numbers() -> bool {
false
}
fn default_word_wrap() -> bool {
true
}
fn default_line_side() -> bool {
false
}
fn default_font_family() -> String {
"Proportional".to_string()
}
fn default_font_size() -> f32 {
14.0
}
fn default_syntax_highlighting() -> bool {
false
}
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Self { Self {
auto_hide_toolbar: false, state_cache: default_state_cache(),
auto_hide_tab_bar: false, auto_hide_toolbar: default_auto_hide_toolbar(),
show_line_numbers: false, hide_tab_bar: default_hide_tab_bar(),
word_wrap: true, show_line_numbers: default_show_line_numbers(),
word_wrap: default_word_wrap(),
theme: Theme::default(), theme: Theme::default(),
line_side: false, line_side: default_line_side(),
font_family: "Proportional".to_string(), font_family: default_font_family(),
font_size: 14.0, font_size: default_font_size(),
syntax_highlighting: default_syntax_highlighting(),
// vim_mode: false, // vim_mode: false,
} }
} }
@ -35,9 +78,9 @@ impl Default for Config {
impl Config { impl Config {
pub fn config_path() -> Option<PathBuf> { pub fn config_path() -> Option<PathBuf> {
let config_dir = if let Some(config_dir) = dirs::config_dir() { let config_dir = if let Some(config_dir) = dirs::config_dir() {
config_dir.join("ced") config_dir.join(env!("CARGO_PKG_NAME"))
} else if let Some(home_dir) = dirs::home_dir() { } else if let Some(home_dir) = dirs::home_dir() {
home_dir.join(".config").join("ced") home_dir.join(".config").join(env!("CARGO_PKG_NAME"))
} else { } else {
return None; return None;
}; };
@ -60,7 +103,8 @@ impl Config {
} }
match std::fs::read_to_string(&config_path) { match std::fs::read_to_string(&config_path) {
Ok(content) => match toml::from_str::<Config>(&content) { Ok(content) => {
let mut config = match toml::from_str::<Config>(&content) {
Ok(config) => config, Ok(config) => config,
Err(e) => { Err(e) => {
eprintln!( eprintln!(
@ -68,9 +112,14 @@ impl Config {
config_path.display(), config_path.display(),
e e
); );
Self::default() return Self::default();
}
};
let default_config = Self::default();
config.merge_with_default(default_config);
config
} }
},
Err(e) => { Err(e) => {
eprintln!( eprintln!(
"Failed to read config file {}: {}", "Failed to read config file {}: {}",
@ -82,6 +131,16 @@ impl Config {
} }
} }
fn merge_with_default(&mut self, default: Config) {
if self.font_family.is_empty() {
self.font_family = default.font_family;
}
if self.font_size <= 0.0 {
self.font_size = default.font_size;
}
}
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> { pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
let config_path = Self::config_path().ok_or("Cannot determine config directory")?; let config_path = Self::config_path().ok_or("Cannot determine config directory")?;

View File

@ -15,6 +15,7 @@ enum ShortcutAction {
ToggleWordWrap, ToggleWordWrap,
ToggleAutoHideToolbar, ToggleAutoHideToolbar,
ToggleFind, ToggleFind,
FocusFind,
NextTab, NextTab,
PrevTab, PrevTab,
PageUp, PageUp,
@ -55,6 +56,11 @@ fn get_shortcuts() -> Vec<ShortcutDefinition> {
egui::Key::W, egui::Key::W,
ShortcutAction::CloseTab, ShortcutAction::CloseTab,
), ),
(
egui::Modifiers::CTRL | egui::Modifiers::SHIFT,
egui::Key::F,
ShortcutAction::FocusFind,
),
( (
egui::Modifiers::CTRL, egui::Modifiers::CTRL,
egui::Key::F, egui::Key::F,
@ -143,7 +149,7 @@ fn get_shortcuts() -> Vec<ShortcutDefinition> {
] ]
} }
fn execute_action(action: ShortcutAction, editor: &mut TextEditor, _ctx: &egui::Context) -> bool { fn execute_action(action: ShortcutAction, editor: &mut TextEditor) -> bool {
match action { match action {
ShortcutAction::NewFile => { ShortcutAction::NewFile => {
io::new_file(editor); io::new_file(editor);
@ -167,15 +173,12 @@ fn execute_action(action: ShortcutAction, editor: &mut TextEditor, _ctx: &egui::
} }
ShortcutAction::CloseTab => { ShortcutAction::CloseTab => {
if editor.tabs.len() > 1 { if editor.tabs.len() > 1 {
// Check if the current tab has unsaved changes
if let Some(current_tab) = editor.get_active_tab() { if let Some(current_tab) = editor.get_active_tab() {
if current_tab.is_modified { if current_tab.is_modified {
// Show dialog for unsaved changes
editor.pending_unsaved_action = Some( editor.pending_unsaved_action = Some(
super::state::UnsavedAction::CloseTab(editor.active_tab_index), super::state::UnsavedAction::CloseTab(editor.active_tab_index),
); );
} else { } else {
// Close tab directly if no unsaved changes
editor.close_tab(editor.active_tab_index); editor.close_tab(editor.active_tab_index);
} }
} }
@ -251,13 +254,25 @@ fn execute_action(action: ShortcutAction, editor: &mut TextEditor, _ctx: &egui::
ShortcutAction::Escape => { ShortcutAction::Escape => {
editor.show_about = false; editor.show_about = false;
editor.show_shortcuts = false; editor.show_shortcuts = false;
if editor.show_find {
editor.should_select_current_match = true;
}
editor.show_find = false; editor.show_find = false;
editor.show_preferences = false; editor.show_preferences = false;
editor.pending_unsaved_action = None; editor.pending_unsaved_action = None;
false false
} }
ShortcutAction::ToggleFind => { ShortcutAction::ToggleFind => {
//editor.show_find = !editor.show_find; editor.show_find = !editor.show_find;
if editor.show_find && !editor.find_query.is_empty() {
editor.update_find_matches();
}
false
}
ShortcutAction::FocusFind => {
if editor.show_find {
editor.focus_find = true;
}
false false
} }
ShortcutAction::Preferences => { ShortcutAction::Preferences => {
@ -276,16 +291,16 @@ pub fn handle(editor: &mut TextEditor, ctx: &egui::Context) {
if i.consume_key(modifiers, key) { if i.consume_key(modifiers, key) {
match action { match action {
ShortcutAction::ZoomIn | ShortcutAction::ZoomOut => { ShortcutAction::ZoomIn | ShortcutAction::ZoomOut => {
font_zoom_occurred = execute_action(action, editor, ctx); font_zoom_occurred = execute_action(action, editor);
} }
ShortcutAction::GlobalZoomIn ShortcutAction::GlobalZoomIn
| ShortcutAction::GlobalZoomOut | ShortcutAction::GlobalZoomOut
| ShortcutAction::ResetZoom => { | ShortcutAction::ResetZoom => {
execute_action(action, editor, ctx); execute_action(action, editor);
global_zoom_occurred = true; global_zoom_occurred = true;
} }
_ => { _ => {
execute_action(action, editor, ctx); execute_action(action, editor);
} }
} }
break; break;
@ -300,4 +315,9 @@ pub fn handle(editor: &mut TextEditor, ctx: &egui::Context) {
if global_zoom_occurred { if global_zoom_occurred {
ctx.set_zoom_factor(editor.zoom_factor); ctx.set_zoom_factor(editor.zoom_factor);
} }
if editor.should_select_current_match {
editor.select_current_match(ctx);
editor.should_select_current_match = false;
}
} }

View File

@ -5,6 +5,7 @@ mod editor;
mod find; mod find;
mod lifecycle; mod lifecycle;
mod processing; mod processing;
mod state_cache;
mod tabs; mod tabs;
mod ui; mod ui;

View File

@ -24,7 +24,7 @@ impl eframe::App for TextEditor {
menu_bar(self, ctx); menu_bar(self, ctx);
if !self.auto_hide_tab_bar { if !self.hide_tab_bar {
tab_bar(self, ctx); tab_bar(self, ctx);
} }
@ -46,7 +46,6 @@ impl eframe::App for TextEditor {
self.show_unsaved_changes_dialog(ctx); self.show_unsaved_changes_dialog(ctx);
} }
// Update the previous find state for next frame
self.prev_show_find = self.show_find; self.prev_show_find = self.show_find;
} }
} }

View File

@ -1,62 +1,69 @@
use super::editor::TextEditor; use super::editor::TextEditor;
use crate::app::config::Config; use crate::app::config::Config;
use crate::app::tab::Tab;
use crate::app::theme; use crate::app::theme;
use crate::io;
use std::path::PathBuf;
impl TextEditor { impl TextEditor {
pub fn from_config(config: Config) -> Self { pub fn from_config(config: Config) -> Self {
Self { Self {
tabs: vec![Tab::new_empty(1)], state_cache: config.state_cache,
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, show_line_numbers: config.show_line_numbers,
word_wrap: config.word_wrap, word_wrap: config.word_wrap,
auto_hide_toolbar: config.auto_hide_toolbar, auto_hide_toolbar: config.auto_hide_toolbar,
auto_hide_tab_bar: config.auto_hide_tab_bar, hide_tab_bar: config.hide_tab_bar,
theme: config.theme, theme: config.theme,
line_side: config.line_side, line_side: config.line_side,
font_family: config.font_family, font_family: config.font_family,
font_size: config.font_size, font_size: config.font_size,
font_size_input: None, syntax_highlighting: config.syntax_highlighting,
zoom_factor: 1.0, ..Default::default()
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,
// vim_mode: config.vim_mode,
// Cursor tracking for smart scrolling
previous_cursor_position: None,
// Track previous content for incremental processing
previous_content: String::new(),
previous_cursor_char_index: None,
current_cursor_line: 0,
previous_cursor_line: 0,
font_settings_changed: false,
} }
} }
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<PathBuf>,
) -> Self {
let mut editor = Self::from_config(config); let mut editor = Self::from_config(config);
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); theme::apply(editor.theme, &cc.egui_ctx);
cc.egui_ctx.options_mut(|o| o.zoom_with_keyboard = false); cc.egui_ctx.options_mut(|o| o.zoom_with_keyboard = false);
let mut style = (*cc.egui_ctx.style()).clone(); let mut style = (*cc.egui_ctx.style()).to_owned();
style style
.text_styles .text_styles
.insert(egui::TextStyle::Body, egui::FontId::proportional(16.0)); .insert(egui::TextStyle::Body, egui::FontId::proportional(16.0));
@ -78,14 +85,16 @@ impl TextEditor {
pub fn get_config(&self) -> Config { pub fn get_config(&self) -> Config {
Config { Config {
state_cache: self.state_cache,
auto_hide_toolbar: self.auto_hide_toolbar, auto_hide_toolbar: self.auto_hide_toolbar,
show_line_numbers: self.show_line_numbers, show_line_numbers: self.show_line_numbers,
auto_hide_tab_bar: self.auto_hide_tab_bar, hide_tab_bar: self.hide_tab_bar,
word_wrap: self.word_wrap, word_wrap: self.word_wrap,
theme: self.theme, theme: self.theme,
line_side: self.line_side, line_side: self.line_side,
font_family: self.font_family.clone(), font_family: self.font_family.to_string(),
font_size: self.font_size, font_size: self.font_size,
syntax_highlighting: self.syntax_highlighting,
// vim_mode: self.vim_mode, // vim_mode: self.vim_mode,
} }
} }

View File

@ -8,6 +8,7 @@ impl Default for TextEditor {
Self { Self {
tabs: vec![Tab::new_empty(1)], tabs: vec![Tab::new_empty(1)],
active_tab_index: 0, active_tab_index: 0,
state_cache: false,
tab_counter: 1, tab_counter: 1,
show_about: false, show_about: false,
show_shortcuts: false, show_shortcuts: false,
@ -19,7 +20,8 @@ impl Default for TextEditor {
show_line_numbers: false, show_line_numbers: false,
word_wrap: true, word_wrap: true,
auto_hide_toolbar: false, auto_hide_toolbar: false,
auto_hide_tab_bar: true, hide_tab_bar: true,
syntax_highlighting: false,
theme: Theme::default(), theme: Theme::default(),
line_side: false, line_side: false,
font_family: "Proportional".to_string(), font_family: "Proportional".to_string(),
@ -31,22 +33,22 @@ impl Default for TextEditor {
menu_bar_stable_until: None, menu_bar_stable_until: None,
text_processing_result: Arc::new(Mutex::new(TextProcessingResult::default())), text_processing_result: Arc::new(Mutex::new(TextProcessingResult::default())),
_processing_thread_handle: None, _processing_thread_handle: None,
// Find functionality
find_query: String::new(), find_query: String::new(),
replace_query: String::new(),
find_matches: Vec::new(), find_matches: Vec::new(),
current_match_index: None, current_match_index: None,
case_sensitive_search: false, case_sensitive_search: false,
show_replace_section: false,
prev_show_find: false, prev_show_find: false,
focus_find: false,
// Cursor tracking for smart scrolling
previous_cursor_position: None, previous_cursor_position: None,
// Track previous content for incremental processing
previous_content: String::new(), previous_content: String::new(),
previous_cursor_char_index: None, previous_cursor_char_index: None,
current_cursor_line: 0, current_cursor_line: 0,
previous_cursor_line: 0, previous_cursor_line: 0,
font_settings_changed: false, font_settings_changed: false,
text_needs_processing: false,
should_select_current_match: false,
} }
} }
} }

View File

@ -13,9 +13,9 @@ pub enum UnsavedAction {
#[derive(Clone)] #[derive(Clone)]
pub struct TextProcessingResult { pub struct TextProcessingResult {
pub line_count: usize, pub line_count: usize,
pub longest_line_index: usize, // Which line is the longest (0-based) pub longest_line_index: usize,
pub longest_line_length: usize, // Character count of the longest line pub longest_line_length: usize,
pub longest_line_pixel_width: f32, // Actual pixel width of the longest line pub longest_line_pixel_width: f32,
} }
impl Default for TextProcessingResult { impl Default for TextProcessingResult {
@ -33,7 +33,8 @@ impl Default for TextProcessingResult {
pub struct TextEditor { pub struct TextEditor {
pub(crate) tabs: Vec<Tab>, pub(crate) tabs: Vec<Tab>,
pub(crate) active_tab_index: usize, pub(crate) active_tab_index: usize,
pub(crate) tab_counter: usize, // Counter for numbering new tabs pub(crate) tab_counter: usize,
pub(crate) state_cache: bool,
pub(crate) show_about: bool, pub(crate) show_about: bool,
pub(crate) show_shortcuts: bool, pub(crate) show_shortcuts: bool,
pub(crate) show_find: bool, pub(crate) show_find: bool,
@ -44,7 +45,8 @@ pub struct TextEditor {
pub(crate) show_line_numbers: bool, pub(crate) show_line_numbers: bool,
pub(crate) word_wrap: bool, pub(crate) word_wrap: bool,
pub(crate) auto_hide_toolbar: bool, pub(crate) auto_hide_toolbar: bool,
pub(crate) auto_hide_tab_bar: bool, pub(crate) hide_tab_bar: bool,
pub(crate) syntax_highlighting: bool,
pub(crate) theme: Theme, pub(crate) theme: Theme,
pub(crate) line_side: bool, pub(crate) line_side: bool,
pub(crate) font_family: String, pub(crate) font_family: String,
@ -57,18 +59,19 @@ pub struct TextEditor {
pub(crate) text_processing_result: Arc<Mutex<TextProcessingResult>>, pub(crate) text_processing_result: Arc<Mutex<TextProcessingResult>>,
pub(crate) _processing_thread_handle: Option<thread::JoinHandle<()>>, pub(crate) _processing_thread_handle: Option<thread::JoinHandle<()>>,
pub(crate) find_query: String, pub(crate) find_query: String,
pub(crate) find_matches: Vec<(usize, usize)>, // (start_pos, end_pos) byte positions pub(crate) replace_query: String,
pub(crate) find_matches: Vec<(usize, usize)>,
pub(crate) current_match_index: Option<usize>, pub(crate) current_match_index: Option<usize>,
pub(crate) case_sensitive_search: bool, pub(crate) case_sensitive_search: bool,
pub(crate) prev_show_find: bool, // Track previous state to detect transitions pub(crate) show_replace_section: bool,
pub(crate) prev_show_find: bool,
// Cursor tracking for smart scrolling pub(crate) focus_find: bool,
pub(crate) previous_cursor_position: Option<usize>,
// Track previous content for incremental processing
pub(crate) previous_content: String, pub(crate) previous_content: String,
pub(crate) previous_cursor_char_index: Option<usize>, pub(crate) previous_cursor_char_index: Option<usize>,
pub(crate) current_cursor_line: usize, // Track current line number incrementally pub(crate) current_cursor_line: usize,
pub(crate) previous_cursor_line: usize, // Track previous line for comparison pub(crate) previous_cursor_line: usize,
pub(crate) font_settings_changed: bool, // Flag to trigger text reprocessing when font changes pub(crate) font_settings_changed: bool,
pub(crate) text_needs_processing: bool,
pub(crate) should_select_current_match: bool,
pub(crate) previous_cursor_position: Option<usize>,
} }

View File

@ -1,7 +1,9 @@
use super::editor::TextEditor; use super::editor::TextEditor;
use eframe::egui;
impl TextEditor { impl TextEditor {
pub fn update_find_matches(&mut self) { pub fn update_find_matches(&mut self) {
let previous_match_index = self.current_match_index;
self.find_matches.clear(); self.find_matches.clear();
self.current_match_index = None; self.current_match_index = None;
@ -12,32 +14,60 @@ impl TextEditor {
if let Some(tab) = self.get_active_tab() { if let Some(tab) = self.get_active_tab() {
let content = &tab.content; let content = &tab.content;
let query = if self.case_sensitive_search { let query = if self.case_sensitive_search {
self.find_query.clone() self.find_query.to_owned()
} else { } else {
self.find_query.to_lowercase() self.find_query.to_lowercase()
}; };
let search_content = if self.case_sensitive_search { let search_content = if self.case_sensitive_search {
content.clone() content.to_string()
} else { } else {
content.to_lowercase() content.to_lowercase()
}; };
let mut start = 0; let mut start = 0;
while let Some(pos) = search_content[start..].find(&query) { while start < search_content.len() {
let search_slice = if search_content.is_char_boundary(start) {
&search_content[start..]
} else {
while start < search_content.len() && !search_content.is_char_boundary(start) {
start += 1;
}
if start >= search_content.len() {
break;
}
&search_content[start..]
};
if let Some(pos) = search_slice.find(&query) {
let absolute_pos = start + pos; let absolute_pos = start + pos;
self.find_matches self.find_matches
.push((absolute_pos, absolute_pos + query.len())); .push((absolute_pos, absolute_pos + query.len()));
start = absolute_pos + 1; start = absolute_pos + 1;
while start < search_content.len() && !search_content.is_char_boundary(start) {
start += 1;
}
} else {
break;
}
} }
if !self.find_matches.is_empty() { if !self.find_matches.is_empty() {
if let Some(prev_index) = previous_match_index {
if prev_index < self.find_matches.len() {
self.current_match_index = Some(prev_index);
} else {
self.current_match_index = Some(0);
}
} else {
self.current_match_index = Some(0); self.current_match_index = Some(0);
} }
} }
} }
}
pub fn find_next(&mut self) { pub fn find_next(&mut self, ctx: &egui::Context) {
if self.find_matches.is_empty() { if self.find_matches.is_empty() {
return; return;
} }
@ -47,9 +77,12 @@ impl TextEditor {
} else { } else {
self.current_match_index = Some(0); self.current_match_index = Some(0);
} }
self.select_current_match(ctx);
self.should_select_current_match = true;
} }
pub fn find_previous(&mut self) { pub fn find_previous(&mut self, ctx: &egui::Context) {
if self.find_matches.is_empty() { if self.find_matches.is_empty() {
return; return;
} }
@ -63,6 +96,9 @@ impl TextEditor {
} else { } else {
self.current_match_index = Some(0); self.current_match_index = Some(0);
} }
self.select_current_match(ctx);
self.should_select_current_match = true;
} }
pub fn get_current_match_position(&self) -> Option<(usize, usize)> { pub fn get_current_match_position(&self) -> Option<(usize, usize)> {
@ -72,4 +108,112 @@ impl TextEditor {
None None
} }
} }
pub fn select_current_match(&self, ctx: &egui::Context) {
if let Some((start_byte, end_byte)) = self.get_current_match_position() {
if let Some(active_tab) = self.get_active_tab() {
let content = &active_tab.content;
let start_char = Self::safe_slice_to_pos(content, start_byte).chars().count();
let end_char = Self::safe_slice_to_pos(content, end_byte).chars().count();
let text_edit_id = egui::Id::new("main_text_editor");
if let Some(mut state) = egui::TextEdit::load_state(ctx, text_edit_id) {
let selection_range = egui::text::CCursorRange::two(
egui::text::CCursor::new(start_char),
egui::text::CCursor::new(end_char),
);
state.cursor.set_char_range(Some(selection_range));
egui::TextEdit::store_state(ctx, text_edit_id, state);
}
}
}
}
pub fn replace_current_match(&mut self, ctx: &egui::Context) {
if self.find_query.is_empty() || self.find_matches.is_empty() {
return;
}
if let Some((start_byte, end_byte)) = self.get_current_match_position() {
let replace_query = self.replace_query.to_owned();
let replacement_end = start_byte + replace_query.len();
if let Some(active_tab) = self.get_active_tab_mut() {
let content = &active_tab.content;
let mut new_content = content.to_string();
new_content.replace_range(start_byte..end_byte, &replace_query);
active_tab.content = new_content;
active_tab.is_modified = true;
}
self.update_find_matches();
if let Some(active_tab) = self.get_active_tab() {
let replacement_end_char =
Self::safe_slice_to_pos(&active_tab.content, replacement_end)
.chars()
.count();
let text_edit_id = egui::Id::new("main_text_editor");
if let Some(mut state) = egui::TextEdit::load_state(ctx, text_edit_id) {
state
.cursor
.set_char_range(Some(egui::text::CCursorRange::one(
egui::text::CCursor::new(replacement_end_char),
)));
egui::TextEdit::store_state(ctx, text_edit_id, state);
}
}
}
}
pub fn replace_all(&mut self, ctx: &egui::Context) {
if self.find_query.is_empty() || self.find_matches.is_empty() {
return;
}
let find_query = self.find_query.to_owned();
let replace_query = self.replace_query.to_owned();
let case_sensitive = self.case_sensitive_search;
let find_matches = self.find_matches.to_owned();
if let Some(active_tab) = self.get_active_tab_mut() {
let content = &active_tab.content;
let new_content = if case_sensitive {
content.replace(&find_query, &replace_query)
} else {
let mut result = String::new();
let mut last_end = 0;
for (start_byte, end_byte) in &find_matches {
result.push_str(&content[last_end..*start_byte]);
result.push_str(&replace_query);
last_end = *end_byte;
}
result.push_str(&content[last_end..]);
result
};
active_tab.content = new_content;
active_tab.is_modified = true;
}
self.update_find_matches();
self.current_match_index = None;
let text_edit_id = egui::Id::new("main_text_editor");
if let Some(mut state) = egui::TextEdit::load_state(ctx, text_edit_id) {
state
.cursor
.set_char_range(Some(egui::text::CCursorRange::one(
egui::text::CCursor::new(0),
)));
egui::TextEdit::store_state(ctx, text_edit_id, state);
}
}
} }

View File

@ -10,21 +10,27 @@ impl TextEditor {
self.tabs self.tabs
.iter() .iter()
.filter(|tab| tab.is_modified) .filter(|tab| tab.is_modified)
.map(|tab| tab.title.clone()) .map(|tab| tab.title.to_owned())
.collect() .collect()
} }
pub fn request_quit(&mut self, ctx: &egui::Context) { pub fn request_quit(&mut self, ctx: &egui::Context) {
if self.has_unsaved_changes() { if self.has_unsaved_changes() && !self.state_cache {
self.pending_unsaved_action = Some(UnsavedAction::Quit); self.pending_unsaved_action = Some(UnsavedAction::Quit);
} else { } else {
self.clean_quit_requested = true; self.clean_quit_requested = true;
if let Err(e) = self.save_state_cache() {
eprintln!("Failed to save state cache: {e}");
}
ctx.send_viewport_cmd(egui::ViewportCommand::Close); ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} }
} }
pub fn force_quit(&mut self, ctx: &egui::Context) { pub fn force_quit(&mut self, ctx: &egui::Context) {
self.force_quit_confirmed = true; self.force_quit_confirmed = true;
if let Err(e) = self.save_state_cache() {
eprintln!("Failed to save state cache: {e}");
}
ctx.send_viewport_cmd(egui::ViewportCommand::Close); ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} }
@ -35,75 +41,70 @@ impl TextEditor {
let (files_to_list, title, confirmation_text, button_text, action) = let (files_to_list, title, confirmation_text, button_text, action) =
if let Some(action) = &self.pending_unsaved_action { if let Some(action) = &self.pending_unsaved_action {
match action { match action {
UnsavedAction::Quit => ( UnsavedAction::Quit => {
self.get_unsaved_files(), let files = self.get_unsaved_files();
let file_plural = if files.len() > 1 { "s" } else { "" };
(
files,
"Unsaved Changes".to_string(), "Unsaved Changes".to_string(),
"You have unsaved changes.".to_string(), format!("File{file_plural} with unsaved changes:"),
"Quit Without Saving".to_string(), "Quit Without Saving".to_string(),
action.clone(), action.to_owned(),
), )
}
UnsavedAction::CloseTab(tab_index) => { UnsavedAction::CloseTab(tab_index) => {
let file_name = self let file_name = self
.tabs .tabs
.get(*tab_index) .get(*tab_index)
.map_or_else(|| "unknown file".to_string(), |tab| tab.title.clone()); .map_or_else(|| "unknown file".to_string(), |tab| tab.title.to_owned());
( (
vec![file_name], vec![file_name],
"Unsaved Changes".to_string(), "Unsaved Changes".to_string(),
"The file has unsaved changes.".to_string(), "This file has unsaved changes:".to_string(),
"Close Without Saving".to_string(), "Close Without Saving".to_string(),
action.clone(), action.to_owned(),
) )
} }
} }
} else { } else {
return; // Should not happen if called correctly return;
}; };
let visuals = &ctx.style().visuals; let visuals = &ctx.style().visuals;
let error_color = visuals.error_fg_color;
egui::Window::new(title) egui::Window::new(title)
.collapsible(false) .collapsible(false)
.resizable(false) .resizable(false)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .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| { .show(ctx, |ui| {
ui.vertical(|ui| { ui.vertical_centered(|ui| {
ui.label(egui::RichText::new(&confirmation_text).size(14.0));
ui.add_space(8.0); ui.add_space(8.0);
ui.label(egui::RichText::new(&confirmation_text).size(14.0));
ui.add_space(4.0);
for file in &files_to_list { for file in &files_to_list {
ui.label(egui::RichText::new(format!("{file}")).size(18.0).weak()); ui.label(egui::RichText::new(file).size(12.0).color(error_color));
} }
ui.add_space(12.0); 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() { ui.horizontal(|ui| {
if ui.button("Cancel").clicked() {
cancel_action = true; cancel_action = true;
} }
ui.add_space(8.0); ui.add_space(8.0);
let destructive_color = ui.visuals().error_fg_color; if ui
let confirm_button = egui::Button::new(&button_text) .button(egui::RichText::new(&button_text).color(error_color))
.fill(destructive_color) .clicked()
.stroke(egui::Stroke::new(1.0, destructive_color)); {
close_action_now = Some(action.to_owned());
if ui.add(confirm_button).clicked() {
close_action_now = Some(action);
} }
}); });
ui.add_space(8.0);
}); });
}); });
@ -113,9 +114,17 @@ impl TextEditor {
if let Some(action) = close_action_now { if let Some(action) = close_action_now {
match action { match action {
UnsavedAction::Quit => self.force_quit(ctx), UnsavedAction::Quit => {
if let Err(e) = self.save_state_cache() {
eprintln!("Failed to save state cache: {e}");
}
self.force_quit(ctx);
}
UnsavedAction::CloseTab(tab_index) => { UnsavedAction::CloseTab(tab_index) => {
self.close_tab(tab_index); self.close_tab(tab_index);
if let Err(e) = self.save_state_cache() {
eprintln!("Failed to save state cache: {e}");
}
} }
} }
self.pending_unsaved_action = None; self.pending_unsaved_action = None;

View File

@ -2,12 +2,21 @@ use super::editor::{TextEditor, TextProcessingResult};
use eframe::egui; use eframe::egui;
impl TextEditor { impl TextEditor {
/// Process text content and find the longest line (only used for initial scan) pub(crate) fn safe_slice_to_pos(content: &str, pos: usize) -> &str {
pub fn process_text_for_rendering(&mut self, content: &str, ui: &egui::Ui) { let pos = pos.min(content.len());
let lines: Vec<&str> = content.lines().collect(); let mut boundary_pos = pos;
let line_count = lines.len().max(1); while boundary_pos > 0 && !content.is_char_boundary(boundary_pos) {
boundary_pos -= 1;
}
&content[..boundary_pos]
}
if lines.is_empty() { pub fn process_text_for_rendering(&mut self, content: &str, ui: &egui::Ui) {
let line_count = content.bytes().filter(|&b| b == b'\n').count() + 1;
let lines: Vec<&str> = content.lines().collect();
if content.is_empty() {
self.update_processing_result(TextProcessingResult { self.update_processing_result(TextProcessingResult {
line_count: 1, line_count: 1,
longest_line_index: 0, longest_line_index: 0,
@ -20,6 +29,16 @@ impl TextEditor {
let mut longest_line_index = 0; let mut longest_line_index = 0;
let mut longest_line_length = 0; let mut longest_line_length = 0;
if lines.is_empty() {
self.update_processing_result(TextProcessingResult {
line_count,
longest_line_index: 0,
longest_line_length: 0,
longest_line_pixel_width: 0.0,
});
return;
}
for (index, line) in lines.iter().enumerate() { for (index, line) in lines.iter().enumerate() {
let char_count = line.chars().count(); let char_count = line.chars().count();
if char_count > longest_line_length { if char_count > longest_line_length {
@ -56,7 +75,6 @@ impl TextEditor {
self.update_processing_result(result); self.update_processing_result(result);
} }
/// Efficiently detect and process line changes without full content iteration
pub fn process_incremental_change( pub fn process_incremental_change(
&mut self, &mut self,
old_content: &str, old_content: &str,
@ -103,7 +121,6 @@ impl TextEditor {
self.previous_cursor_line = self.current_cursor_line; self.previous_cursor_line = self.current_cursor_line;
} }
/// Calculate the change in cursor line without full iteration
fn calculate_cursor_line_change( fn calculate_cursor_line_change(
&self, &self,
old_content: &str, old_content: &str,
@ -111,12 +128,12 @@ impl TextEditor {
old_cursor_pos: usize, old_cursor_pos: usize,
new_cursor_pos: usize, new_cursor_pos: usize,
) -> isize { ) -> isize {
let old_newlines = old_content[..old_cursor_pos.min(old_content.len())] let old_newlines = Self::safe_slice_to_pos(old_content, old_cursor_pos)
.bytes() .bytes()
.filter(|&b| b == b'\n') .filter(|&b| b == b'\n')
.count(); .count();
let new_newlines = new_content[..new_cursor_pos.min(new_content.len())] let new_newlines = Self::safe_slice_to_pos(new_content, new_cursor_pos)
.bytes() .bytes()
.filter(|&b| b == b'\n') .filter(|&b| b == b'\n')
.count(); .count();
@ -124,7 +141,6 @@ impl TextEditor {
new_newlines as isize - old_newlines as isize new_newlines as isize - old_newlines as isize
} }
/// Handle character replacement (same length change)
fn handle_character_replacement( fn handle_character_replacement(
&mut self, &mut self,
_old_content: &str, _old_content: &str,
@ -144,7 +160,6 @@ impl TextEditor {
); );
} }
/// Handle content addition
fn handle_content_addition( fn handle_content_addition(
&mut self, &mut self,
old_content: &str, old_content: &str,
@ -156,7 +171,6 @@ impl TextEditor {
let min_len = old_content.len().min(new_content.len()); let min_len = old_content.len().min(new_content.len());
let mut common_prefix = 0; let mut common_prefix = 0;
let mut common_suffix = 0; let mut common_suffix = 0;
for i in 0..min_len { for i in 0..min_len {
if old_content.as_bytes()[i] == new_content.as_bytes()[i] { if old_content.as_bytes()[i] == new_content.as_bytes()[i] {
common_prefix += 1; common_prefix += 1;
@ -183,12 +197,29 @@ impl TextEditor {
if newlines_added > 0 { if newlines_added > 0 {
let mut current_result = self.get_text_processing_result(); let mut current_result = self.get_text_processing_result();
current_result.line_count += newlines_added; current_result.line_count += newlines_added;
let addition_start_line = Self::safe_slice_to_pos(old_content, added_start)
.bytes()
.filter(|&b| b == b'\n')
.count();
let addition_end_line = Self::safe_slice_to_pos(old_content, added_end)
.bytes()
.filter(|&b| b == b'\n')
.count();
if current_result.longest_line_index >= addition_start_line
&& current_result.longest_line_index <= addition_end_line
{
self.process_text_for_rendering(new_content, ui);
} else {
if addition_end_line < current_result.longest_line_index {
current_result.longest_line_index += newlines_added;
}
self.update_processing_result(current_result); self.update_processing_result(current_result);
} }
} else {
let current_line = self.extract_current_line(new_content, new_cursor_pos); let current_line = self.extract_current_line(new_content, new_cursor_pos);
let current_line_length = current_line.chars().count(); let current_line_length = current_line.chars().count();
self.update_line_if_longer( self.update_line_if_longer(
self.current_cursor_line, self.current_cursor_line,
&current_line, &current_line,
@ -196,8 +227,8 @@ impl TextEditor {
ui, ui,
); );
} }
}
/// Handle content removal
fn handle_content_removal( fn handle_content_removal(
&mut self, &mut self,
old_content: &str, old_content: &str,
@ -237,12 +268,28 @@ impl TextEditor {
let mut current_result = self.get_text_processing_result(); let mut current_result = self.get_text_processing_result();
current_result.line_count = current_result.line_count.saturating_sub(newlines_removed); current_result.line_count = current_result.line_count.saturating_sub(newlines_removed);
if self.current_cursor_line <= current_result.longest_line_index { let removal_start_line = Self::safe_slice_to_pos(old_content, removed_start)
self.process_text_for_rendering(new_content, ui); .bytes()
} .filter(|&b| b == b'\n')
.count();
let removal_end_line = Self::safe_slice_to_pos(old_content, removed_end)
.bytes()
.filter(|&b| b == b'\n')
.count();
if current_result.longest_line_index >= removal_start_line
&& current_result.longest_line_index <= removal_end_line
{
self.process_text_for_rendering(new_content, ui);
} else {
if removal_end_line < current_result.longest_line_index {
current_result.longest_line_index = current_result
.longest_line_index
.saturating_sub(newlines_removed);
}
self.update_processing_result(current_result); self.update_processing_result(current_result);
} }
}
let current_line = self.extract_current_line(new_content, new_cursor_pos); let current_line = self.extract_current_line(new_content, new_cursor_pos);
let current_line_length = current_line.chars().count(); let current_line_length = current_line.chars().count();
@ -262,24 +309,32 @@ impl TextEditor {
} }
} }
/// Extract the current line efficiently without full content scan
fn extract_current_line(&self, content: &str, cursor_pos: usize) -> String { fn extract_current_line(&self, content: &str, cursor_pos: usize) -> String {
let bytes = content.as_bytes(); let bytes = content.as_bytes();
let safe_cursor_pos = cursor_pos.min(bytes.len());
let mut line_start = cursor_pos; let mut line_start = safe_cursor_pos;
while line_start > 0 && bytes[line_start - 1] != b'\n' { while line_start > 0 && bytes[line_start - 1] != b'\n' {
line_start -= 1; line_start -= 1;
} }
let mut line_end = cursor_pos; let mut line_end = safe_cursor_pos;
while line_end < bytes.len() && bytes[line_end] != b'\n' { while line_end < bytes.len() && bytes[line_end] != b'\n' {
line_end += 1; line_end += 1;
} }
content[line_start..line_end].to_string() let line_start_boundary = line_start;
let line_end_boundary = line_end;
if content.is_char_boundary(line_start_boundary)
&& content.is_char_boundary(line_end_boundary)
{
content[line_start_boundary..line_end_boundary].to_string()
} else {
Self::safe_slice_to_pos(content, line_end_boundary)[line_start_boundary..].to_string()
}
} }
/// Update longest line info if the current line is longer
fn update_line_if_longer( fn update_line_if_longer(
&mut self, &mut self,
line_index: usize, line_index: usize,
@ -314,15 +369,13 @@ impl TextEditor {
} }
} }
/// Get the current text processing result
pub fn get_text_processing_result(&self) -> TextProcessingResult { pub fn get_text_processing_result(&self) -> TextProcessingResult {
self.text_processing_result self.text_processing_result
.lock() .lock()
.map(|result| result.clone()) .map(|result| result.to_owned())
.unwrap_or_default() .unwrap_or_default()
} }
/// Update the processing result atomically
fn update_processing_result(&self, result: TextProcessingResult) { fn update_processing_result(&self, result: TextProcessingResult) {
if let Ok(mut processing_result) = self.text_processing_result.lock() { if let Ok(mut processing_result) = self.text_processing_result.lock() {
*processing_result = result; *processing_result = result;

View File

@ -0,0 +1,255 @@
use super::editor::TextEditor;
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_file: Option<PathBuf>,
pub full_content: Option<String>, // This is used for 'new files' that don't have a path
pub file_path: Option<PathBuf>,
pub is_modified: bool,
pub title: String,
pub original_content_hash: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateCache {
pub tabs: Vec<CachedTab>,
pub active_tab_index: usize,
pub tab_counter: usize,
}
fn create_diff_file(diff_content: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
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_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_file,
full_content: None,
file_path: tab.file_path.clone(),
is_modified: tab.is_modified,
title: tab.title.clone(),
original_content_hash: tab.original_content_hash,
}
} else {
Self {
diff_file: None,
full_content: Some(tab.content.clone()),
file_path: None,
is_modified: tab.is_modified,
title: tab.title.clone(),
original_content_hash: tab.original_content_hash,
}
}
}
}
impl From<CachedTab> 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_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 parse diff for {}, using original content",
file_path.display()
);
original_content
}
}
}
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 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();
let mut tab = Tab::new_empty(1);
tab.content = content;
tab.title = cached.title;
tab.is_modified = cached.is_modified;
tab.original_content_hash = cached.original_content_hash;
tab
}
}
}
impl TextEditor {
pub fn state_cache_path() -> Option<PathBuf> {
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("state.json"))
}
pub fn diffs_cache_dir() -> Option<PathBuf> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(());
}
let content = std::fs::read_to_string(&cache_path)?;
let state_cache: StateCache = serde_json::from_str(&content)?;
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.tab_counter = state_cache.tab_counter;
self.text_needs_processing = true;
}
Ok(())
}
pub fn save_state_cache(&self) -> Result<(), Box<dyn std::error::Error>> {
if !self.state_cache {
return Ok(());
}
let cache_path = Self::state_cache_path().ok_or("Cannot determine cache directory")?;
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent)?;
}
let state_cache = StateCache {
tabs: self.tabs.iter().map(CachedTab::from).collect(),
active_tab_index: self.active_tab_index,
tab_counter: self.tab_counter,
};
let active_diff_files: Vec<PathBuf> = 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)?;
Ok(())
}
pub fn clear_state_cache() -> Result<(), Box<dyn std::error::Error>> {
if let Some(cache_path) = Self::state_cache_path() {
if cache_path.exists() {
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(())
}
}

View File

@ -14,6 +14,14 @@ impl TextEditor {
self.tab_counter += 1; self.tab_counter += 1;
self.tabs.push(Tab::new_empty(self.tab_counter)); self.tabs.push(Tab::new_empty(self.tab_counter));
self.active_tab_index = self.tabs.len() - 1; self.active_tab_index = self.tabs.len() - 1;
if self.show_find && !self.find_query.is_empty() {
self.update_find_matches();
}
self.text_needs_processing = true;
if let Err(e) = self.save_state_cache() {
eprintln!("Failed to save state cache: {e}");
}
} }
pub fn close_tab(&mut self, tab_index: usize) { pub fn close_tab(&mut self, tab_index: usize) {
@ -24,12 +32,28 @@ impl TextEditor {
} else if self.active_tab_index > tab_index { } else if self.active_tab_index > tab_index {
self.active_tab_index -= 1; self.active_tab_index -= 1;
} }
if self.show_find && !self.find_query.is_empty() {
self.update_find_matches();
}
self.text_needs_processing = true;
if let Err(e) = self.save_state_cache() {
eprintln!("Failed to save state cache: {e}");
}
} }
} }
pub fn switch_to_tab(&mut self, tab_index: usize) { pub fn switch_to_tab(&mut self, tab_index: usize) {
if tab_index < self.tabs.len() { if tab_index < self.tabs.len() {
self.active_tab_index = tab_index; self.active_tab_index = tab_index;
if self.show_find && !self.find_query.is_empty() {
self.update_find_matches();
}
self.text_needs_processing = true;
if let Err(e) = self.save_state_cache() {
eprintln!("Failed to save state cache: {e}");
}
} }
} }
} }

View File

@ -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 { pub fn get_font_id(&self) -> egui::FontId {
let font_family = match self.font_family.as_str() { let font_family = match self.font_family.as_str() {
"Monospace" => egui::FontFamily::Monospace, "Monospace" => egui::FontFamily::Monospace,
@ -32,20 +31,18 @@ impl TextEditor {
egui::FontId::new(self.font_size, font_family) egui::FontId::new(self.font_size, font_family)
} }
/// Immediately apply theme and save to configuration
pub fn set_theme(&mut self, ctx: &egui::Context) { pub fn set_theme(&mut self, ctx: &egui::Context) {
theme::apply(self.theme, ctx); theme::apply(self.theme, ctx);
self.save_config(); self.save_config();
} }
/// Apply font settings with immediate text reprocessing
pub fn apply_font_settings(&mut self, ctx: &egui::Context) { pub fn apply_font_settings(&mut self, ctx: &egui::Context) {
let font_family = match self.font_family.as_str() { let font_family = match self.font_family.as_str() {
"Monospace" => egui::FontFamily::Monospace, "Monospace" => egui::FontFamily::Monospace,
_ => egui::FontFamily::Proportional, _ => egui::FontFamily::Proportional,
}; };
let mut style = (*ctx.style()).clone(); let mut style = (*ctx.style()).to_owned();
style.text_styles.insert( style.text_styles.insert(
egui::TextStyle::Monospace, egui::TextStyle::Monospace,
egui::FontId::new(self.font_size, font_family), egui::FontId::new(self.font_size, font_family),
@ -56,24 +53,6 @@ impl TextEditor {
self.save_config(); 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() {
let content = active_tab.content.clone();
if !content.is_empty() {
self.process_text_for_rendering(&content, 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 { pub fn calculate_editor_dimensions(&self, ui: &egui::Ui) -> EditorDimensions {
let total_available_width = ui.available_width(); let total_available_width = ui.available_width();
@ -117,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 { pub fn calculate_content_based_width(&self, ui: &egui::Ui) -> f32 {
let processing_result = self.get_text_processing_result(); let processing_result = self.get_text_processing_result();
@ -125,7 +103,8 @@ impl TextEditor {
return self.calculate_editor_dimensions(ui).text_width; return self.calculate_editor_dimensions(ui).text_width;
} }
let longest_line_width = processing_result.longest_line_pixel_width + self.font_size; let longest_line_width =
processing_result.longest_line_pixel_width + (self.font_size * 2.0);
let dimensions = self.calculate_editor_dimensions(ui); let dimensions = self.calculate_editor_dimensions(ui);
longest_line_width.max(dimensions.text_width) longest_line_width.max(dimensions.text_width)

View File

@ -2,8 +2,9 @@ use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::path::PathBuf; use std::path::PathBuf;
pub fn compute_content_hash(content: &str, hasher: &mut DefaultHasher) -> u64 { pub fn compute_content_hash(content: &str) -> u64 {
content.hash(hasher); let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
hasher.finish() hasher.finish()
} }
@ -11,26 +12,21 @@ pub fn compute_content_hash(content: &str, hasher: &mut DefaultHasher) -> u64 {
pub struct Tab { pub struct Tab {
pub content: String, pub content: String,
pub original_content_hash: u64, pub original_content_hash: u64,
pub last_content_hash: u64,
pub file_path: Option<PathBuf>, pub file_path: Option<PathBuf>,
pub is_modified: bool, pub is_modified: bool,
pub title: String, pub title: String,
pub hasher: DefaultHasher,
} }
impl Tab { impl Tab {
pub fn new_empty(tab_number: usize) -> Self { pub fn new_empty(tab_number: usize) -> Self {
let content = String::new(); let content = String::new();
let mut hasher = DefaultHasher::new(); let hash = compute_content_hash(&content);
let hash = compute_content_hash(&content, &mut hasher);
Self { Self {
original_content_hash: hash, original_content_hash: hash,
last_content_hash: hash,
content, content,
file_path: None, file_path: None,
is_modified: false, is_modified: false,
title: format!("new_{tab_number}"), title: format!("new_{tab_number}"),
hasher,
} }
} }
@ -38,19 +34,16 @@ impl Tab {
let title = file_path let title = file_path
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.unwrap_or("Untitled") .unwrap_or("UNKNOWN")
.to_string(); .to_string();
let mut hasher = DefaultHasher::new(); let hash = compute_content_hash(&content);
let hash = compute_content_hash(&content, &mut hasher);
Self { Self {
original_content_hash: hash, original_content_hash: hash,
last_content_hash: hash,
content, content,
file_path: Some(file_path), file_path: Some(file_path),
is_modified: false, is_modified: false,
title, title,
hasher,
} }
} }
@ -60,21 +53,16 @@ impl Tab {
} }
pub fn update_modified_state(&mut self) { 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_") { if self.title.starts_with("new_") {
self.is_modified = !self.content.is_empty(); self.is_modified = !self.content.is_empty();
} else { } else {
let current_hash = compute_content_hash(&self.content, &mut self.hasher); let current_hash = compute_content_hash(&self.content);
self.is_modified = current_hash != self.last_content_hash; self.is_modified = current_hash != self.original_content_hash;
self.last_content_hash = current_hash;
} }
} }
pub fn mark_as_saved(&mut self) { 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);
self.original_content_hash = compute_content_hash(&self.content, &mut self.hasher);
self.last_content_hash = self.original_content_hash;
self.is_modified = false; self.is_modified = false;
} }
} }

View File

@ -1,4 +1,9 @@
use eframe::egui; 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)] #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize, Default)]
pub enum Theme { pub enum Theme {
@ -194,3 +199,127 @@ fn detect_system_dark_mode() -> bool {
true 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)
}

192
src/io.rs
View File

@ -7,6 +7,116 @@ pub(crate) fn new_file(app: &mut TextEditor) {
app.add_new_tab(); 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<usize, String> {
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<PathBuf> = 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) { pub(crate) fn open_file(app: &mut TextEditor) {
if let Some(path) = rfd::FileDialog::new() if let Some(path) = rfd::FileDialog::new()
.add_filter("Text files", &["*"]) .add_filter("Text files", &["*"])
@ -14,7 +124,6 @@ pub(crate) fn open_file(app: &mut TextEditor) {
{ {
match fs::read_to_string(&path) { match fs::read_to_string(&path) {
Ok(content) => { 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() { let should_replace_current_tab = if let Some(active_tab) = app.get_active_tab() {
active_tab.file_path.is_none() active_tab.file_path.is_none()
&& active_tab.content.is_empty() && active_tab.content.is_empty()
@ -24,22 +133,29 @@ pub(crate) fn open_file(app: &mut TextEditor) {
}; };
if should_replace_current_tab { if should_replace_current_tab {
// Replace the current empty tab
if let Some(active_tab) = app.get_active_tab_mut() { if let Some(active_tab) = app.get_active_tab_mut() {
active_tab.content = content; let title = path
active_tab.file_path = Some(path.clone());
active_tab.title = path
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.unwrap_or("Untitled") .unwrap_or("Untitled");
.to_string(); active_tab.content = content;
active_tab.mark_as_saved(); // This will set the hash and mark as not modified 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 { } else {
// Create a new tab as before
let new_tab = Tab::new_with_file(content, path); let new_tab = Tab::new_with_file(content, path);
app.tabs.push(new_tab); app.tabs.push(new_tab);
app.active_tab_index = app.tabs.len() - 1; 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}");
} }
} }
Err(err) => { Err(err) => {
@ -49,10 +165,54 @@ 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) { pub(crate) fn save_file(app: &mut TextEditor) {
if let Some(active_tab) = app.get_active_tab() { if let Some(active_tab) = app.get_active_tab() {
if let Some(path) = &active_tab.file_path { if let Some(path) = &active_tab.file_path {
save_to_path(app, path.clone()); save_to_path(app, path.to_path_buf());
} else { } else {
save_as_file(app); save_as_file(app);
} }
@ -72,13 +232,17 @@ pub(crate) fn save_to_path(app: &mut TextEditor, path: PathBuf) {
if let Some(active_tab) = app.get_active_tab_mut() { if let Some(active_tab) = app.get_active_tab_mut() {
match fs::write(&path, &active_tab.content) { match fs::write(&path, &active_tab.content) {
Ok(()) => { Ok(()) => {
active_tab.file_path = Some(path.clone()); let title = path
active_tab.title = path
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.unwrap_or("Untitled") .unwrap_or("Untitled");
.to_string(); active_tab.file_path = Some(path.to_path_buf());
active_tab.title = title.to_string();
active_tab.mark_as_saved(); active_tab.mark_as_saved();
if let Err(e) = app.save_state_cache() {
eprintln!("Failed to save state cache: {e}");
}
} }
Err(err) => { Err(err) => {
eprintln!("Failed to save file: {err}"); eprintln!("Failed to save file: {err}");

View File

@ -1,6 +1,9 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use eframe::egui; use eframe::egui;
use std::env;
use std::io::IsTerminal;
use std::path::PathBuf;
mod app; mod app;
mod io; mod io;
@ -8,6 +11,14 @@ mod ui;
use app::{config::Config, TextEditor}; use app::{config::Config, TextEditor};
fn main() -> eframe::Result { fn main() -> eframe::Result {
let args: Vec<String> = env::args().collect();
let initial_paths: Vec<PathBuf> = 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 { let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
.with_min_inner_size([600.0, 400.0]) .with_min_inner_size([600.0, 400.0])
@ -21,6 +32,12 @@ fn main() -> eframe::Result {
eframe::run_native( eframe::run_native(
"ced", "ced",
options, options,
Box::new(move |cc| Ok(Box::new(TextEditor::from_config_with_context(config, cc)))), Box::new(move |cc| {
Ok(Box::new(TextEditor::from_config_with_context(
config,
cc,
initial_paths,
)))
}),
) )
} }

View File

@ -1,5 +1,6 @@
pub(crate) mod about_window; pub(crate) mod about_window;
pub(crate) mod central_panel; pub(crate) mod central_panel;
pub(crate) mod constants;
pub(crate) mod find_window; pub(crate) mod find_window;
pub(crate) mod menu_bar; pub(crate) mod menu_bar;
pub(crate) mod preferences_window; pub(crate) mod preferences_window;

View File

@ -1,4 +1,5 @@
use crate::app::TextEditor; use crate::app::TextEditor;
use crate::ui::constants::*;
use eframe::egui; use eframe::egui;
pub(crate) fn about_window(app: &mut TextEditor, ctx: &egui::Context) { pub(crate) fn about_window(app: &mut TextEditor, ctx: &egui::Context) {
@ -11,23 +12,25 @@ pub(crate) fn about_window(app: &mut TextEditor, ctx: &egui::Context) {
.collapsible(false) .collapsible(false)
.resizable(false) .resizable(false)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.fade_in(true)
.fade_out(true)
.frame(egui::Frame { .frame(egui::Frame {
fill: visuals.window_fill, fill: visuals.window_fill,
stroke: visuals.window_stroke, stroke: visuals.window_stroke,
corner_radius: egui::CornerRadius::same(8), corner_radius: egui::CornerRadius::same(CORNER_RADIUS),
shadow: visuals.window_shadow, shadow: visuals.window_shadow,
inner_margin: egui::Margin::same(16), inner_margin: egui::Margin::same(INNER_MARGIN),
outer_margin: egui::Margin::same(0), outer_margin: egui::Margin::same(0),
}) })
.show(ctx, |ui| { .show(ctx, |ui| {
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
ui.label( ui.label(
egui::RichText::new("A stupidly simple, responsive text editor.") egui::RichText::new("A stupidly simple, responsive text editor.")
.size(14.0) .size(UI_TEXT_SIZE)
.weak(), .weak(),
); );
ui.add_space(12.0); ui.add_space(LARGE);
let visuals = ui.visuals(); let visuals = ui.visuals();
let close_button = egui::Button::new("Close") let close_button = egui::Button::new("Close")
.fill(visuals.widgets.inactive.bg_fill) .fill(visuals.widgets.inactive.bg_fill)

View File

@ -1,9 +1,12 @@
mod editor; mod editor;
mod find_highlight; mod find_highlight;
mod languages;
mod line_numbers; mod line_numbers;
use crate::app::TextEditor; use crate::app::TextEditor;
use crate::ui::constants::*;
use eframe::egui; use eframe::egui;
use egui::UiKind;
use self::editor::editor_view_ui; use self::editor::editor_view_ui;
use self::line_numbers::{get_visual_line_mapping, render_line_numbers}; use self::line_numbers::{get_visual_line_mapping, render_line_numbers};
@ -35,7 +38,7 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
editor_view_ui(ui, app); editor_view_ui(ui, app);
}); });
show_context_menu(ui, app, &context_response); handle_empty(ui, app, &context_response);
}); });
return; return;
} }
@ -72,39 +75,35 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
}; };
let separator_widget = |ui: &mut egui::Ui| { let separator_widget = |ui: &mut egui::Ui| {
ui.add_space(3.0); ui.add_space(SMALL);
let separator_x = ui.cursor().left(); let separator_x = ui.cursor().left();
let mut y_range = ui.available_rect_before_wrap().y_range(); 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 y_range.max += 2.0 * font_size;
ui.painter() ui.painter()
.vline(separator_x, y_range, ui.visuals().window_stroke); .vline(separator_x, y_range, ui.visuals().window_stroke);
ui.add_space(4.0); ui.add_space(SMALL);
}; };
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false; 2]) .auto_shrink([false; 2])
.show(ui, |ui| { .show(ui, |ui| {
if line_side { if line_side {
// Line numbers on the right
let text_editor_width = let text_editor_width =
editor_dimensions.text_width + editor_dimensions.total_reserved_width; editor_dimensions.text_width + editor_dimensions.total_reserved_width;
ui.allocate_ui_with_layout( ui.allocate_ui_with_layout(
egui::vec2(text_editor_width, editor_height), egui::vec2(text_editor_width, editor_height),
egui::Layout::left_to_right(egui::Align::TOP), egui::Layout::left_to_right(egui::Align::TOP),
|ui| { |ui| {
// Constrain editor to specific width to leave space for line numbers
ui.allocate_ui_with_layout( ui.allocate_ui_with_layout(
egui::vec2(editor_dimensions.text_width, editor_height), egui::vec2(editor_dimensions.text_width, editor_height),
egui::Layout::left_to_right(egui::Align::TOP), egui::Layout::left_to_right(egui::Align::TOP),
|ui| { |ui| {
// Create an invisible interaction area for context menu
let full_rect = ui.available_rect_before_wrap(); let full_rect = ui.available_rect_before_wrap();
let context_response = ui.allocate_response( let context_response = ui.allocate_response(
full_rect.size(), full_rect.size(),
egui::Sense::click(), egui::Sense::click(),
); );
// Reset cursor to render editor at the top
ui.scope_builder( ui.scope_builder(
egui::UiBuilder::new().max_rect(full_rect), egui::UiBuilder::new().max_rect(full_rect),
|ui| { |ui| {
@ -112,7 +111,7 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
}, },
); );
show_context_menu(ui, app, &context_response); handle_empty(ui, app, &context_response);
}, },
); );
separator_widget(ui); separator_widget(ui);
@ -120,7 +119,6 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
}, },
); );
} else { } else {
// Line numbers on the left
let text_editor_width = let text_editor_width =
editor_dimensions.text_width + editor_dimensions.total_reserved_width; editor_dimensions.text_width + editor_dimensions.total_reserved_width;
ui.allocate_ui_with_layout( ui.allocate_ui_with_layout(
@ -130,12 +128,10 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
line_numbers_widget(ui); line_numbers_widget(ui);
separator_widget(ui); separator_widget(ui);
// Create an invisible interaction area for context menu
let editor_area = ui.available_rect_before_wrap(); let editor_area = ui.available_rect_before_wrap();
let context_response = let context_response =
ui.allocate_response(editor_area.size(), egui::Sense::click()); ui.allocate_response(editor_area.size(), egui::Sense::click());
// Reset cursor to render editor at the current position
ui.scope_builder( ui.scope_builder(
egui::UiBuilder::new().max_rect(editor_area), egui::UiBuilder::new().max_rect(editor_area),
|ui| { |ui| {
@ -143,7 +139,7 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
}, },
); );
show_context_menu(ui, app, &context_response); handle_empty(ui, app, &context_response);
}, },
); );
} }
@ -151,7 +147,25 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
}); });
} }
fn show_context_menu(_ui: &mut egui::Ui, app: &mut TextEditor, context_response: &egui::Response) { fn handle_empty(_ui: &mut egui::Ui, app: &mut TextEditor, context_response: &egui::Response) {
if context_response.clicked() {
let text_edit_id = egui::Id::new("main_text_editor");
if let Some(mut state) = egui::TextEdit::load_state(_ui.ctx(), text_edit_id) {
if let Some(active_tab) = app.get_active_tab() {
let text_len = active_tab.content.len();
let cursor_pos = egui::text::CCursor::new(text_len);
state
.cursor
.set_char_range(Some(egui::text::CCursorRange::one(cursor_pos)));
egui::TextEdit::store_state(_ui.ctx(), text_edit_id, state);
_ui.ctx().memory_mut(|mem| {
mem.request_focus(text_edit_id);
});
}
}
}
context_response.context_menu(|ui| { context_response.context_menu(|ui| {
let text_len = app.get_active_tab().unwrap().content.len(); let text_len = app.get_active_tab().unwrap().content.len();
let reset_zoom_key = egui::Id::new("editor_reset_zoom"); let reset_zoom_key = egui::Id::new("editor_reset_zoom");
@ -159,17 +173,17 @@ fn show_context_menu(_ui: &mut egui::Ui, app: &mut TextEditor, context_response:
if ui.button("Cut").clicked() { if ui.button("Cut").clicked() {
ui.ctx() ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::RequestCut); .send_viewport_cmd(egui::ViewportCommand::RequestCut);
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
if ui.button("Copy").clicked() { if ui.button("Copy").clicked() {
ui.ctx() ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::RequestCopy); .send_viewport_cmd(egui::ViewportCommand::RequestCopy);
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
if ui.button("Paste").clicked() { if ui.button("Paste").clicked() {
ui.ctx() ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::RequestPaste); .send_viewport_cmd(egui::ViewportCommand::RequestPaste);
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
if ui.button("Delete").clicked() { if ui.button("Delete").clicked() {
ui.ctx().input_mut(|i| { ui.ctx().input_mut(|i| {
@ -181,7 +195,7 @@ fn show_context_menu(_ui: &mut egui::Ui, app: &mut TextEditor, context_response:
modifiers: egui::Modifiers::NONE, modifiers: egui::Modifiers::NONE,
}) })
}); });
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
if ui.button("Select All").clicked() { if ui.button("Select All").clicked() {
let text_edit_id = egui::Id::new("main_text_editor"); let text_edit_id = egui::Id::new("main_text_editor");
@ -193,14 +207,14 @@ fn show_context_menu(_ui: &mut egui::Ui, app: &mut TextEditor, context_response:
state.cursor.set_char_range(Some(select_all_range)); state.cursor.set_char_range(Some(select_all_range));
egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); egui::TextEdit::store_state(ui.ctx(), text_edit_id, state);
} }
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
ui.separator(); ui.separator();
if ui.button("Reset Zoom").clicked() { if ui.button("Reset Zoom").clicked() {
ui.ctx().memory_mut(|mem| { ui.ctx().memory_mut(|mem| {
mem.data.insert_temp(reset_zoom_key, true); mem.data.insert_temp(reset_zoom_key, true);
}); });
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
}); });
} }

View File

@ -1,5 +1,8 @@
use crate::app::TextEditor; use crate::app::TextEditor;
use eframe::egui; use eframe::egui;
use egui_extras::syntax_highlighting::{self};
use super::find_highlight;
pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::Response { pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::Response {
let _current_match_position = app.get_current_match_position(); let _current_match_position = app.get_current_match_position();
@ -10,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 show_shortcuts = app.show_shortcuts;
let word_wrap = app.word_wrap; let word_wrap = app.word_wrap;
let font_size = app.font_size; let font_size = app.font_size;
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 reset_zoom_key = egui::Id::new("editor_reset_zoom");
let should_reset_zoom = ui let should_reset_zoom = ui
@ -30,6 +35,18 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R
0.0 0.0
}; };
let find_data = if show_find && !app.find_matches.is_empty() {
app.get_active_tab().map(|tab| {
(
tab.content.to_owned(),
app.find_matches.to_owned(),
app.current_match_index,
)
})
} else {
None
};
let Some(active_tab) = app.get_active_tab_mut() else { let Some(active_tab) = app.get_active_tab_mut() else {
return ui.label("No file open, how did you get here?"); return ui.label("No file open, how did you get here?");
}; };
@ -38,20 +55,83 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R
let editor_rect = ui.available_rect_before_wrap(); let editor_rect = ui.available_rect_before_wrap();
ui.painter().rect_filled(editor_rect, 0.0, bg_color); ui.painter().rect_filled(editor_rect, 0.0, bg_color);
if let Some((content, matches, current_match_index)) = &find_data {
let font_id = ui
.style()
.text_styles
.get(&egui::TextStyle::Monospace)
.unwrap_or(&egui::FontId::monospace(font_size))
.to_owned();
let desired_width = if word_wrap { let desired_width = if word_wrap {
ui.available_width() ui.available_width()
} else { } else {
f32::INFINITY f32::INFINITY
}; };
let temp_galley = ui.fonts(|fonts| {
fonts.layout(
content.to_owned(),
font_id.to_owned(),
ui.visuals().text_color(),
desired_width,
)
});
let text_area_left = editor_rect.left() + 4.0;
let text_area_top = editor_rect.top() + 2.0;
find_highlight::draw_find_highlights(
ui,
content,
matches,
*current_match_index,
&temp_galley,
text_area_left,
text_area_top,
font_size,
);
}
let desired_width = if word_wrap {
ui.available_width()
} else {
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) let text_edit = egui::TextEdit::multiline(&mut active_tab.content)
.frame(false) .frame(false)
.font(egui::TextStyle::Monospace)
.code_editor() .code_editor()
.desired_width(desired_width) .desired_width(desired_width)
.desired_rows(0) .desired_rows(0)
.lock_focus(true) .lock_focus(!show_find)
.cursor_at_end(false) .cursor_at_end(false)
.layouter(&mut layouter)
.id(egui::Id::new("main_text_editor")); .id(egui::Id::new("main_text_editor"));
let output = if word_wrap { let output = if word_wrap {
@ -73,11 +153,21 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R
let content_changed = output.response.changed(); let content_changed = output.response.changed();
let content_for_processing = if content_changed { let content_for_processing = if content_changed {
active_tab.update_modified_state(); active_tab.update_modified_state();
Some(active_tab.content.clone()) Some(active_tab.content.to_owned())
} else { } else {
None None
}; };
if content_changed {
if let Err(e) = app.save_state_cache() {
eprintln!("Failed to save state cache: {e}");
}
}
if content_changed && app.show_find && !app.find_query.is_empty() {
app.update_find_matches();
}
let current_cursor_pos = output let current_cursor_pos = output
.state .state
.cursor .cursor
@ -85,7 +175,7 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R
.map(|range| range.primary.index); .map(|range| range.primary.index);
if let Some(content) = content_for_processing { if let Some(content) = content_for_processing {
let previous_content = app.previous_content.clone(); let previous_content = app.previous_content.to_owned();
let previous_cursor_pos = app.previous_cursor_char_index; let previous_cursor_pos = app.previous_cursor_char_index;
if !previous_content.is_empty() { if !previous_content.is_empty() {
@ -99,36 +189,22 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R
curr_cursor_pos, curr_cursor_pos,
ui, ui,
); );
}
} else { } else {
app.process_text_for_rendering(&content, ui); app.process_text_for_rendering(&content, ui);
if let Some(cursor_pos) = current_cursor_pos {
app.current_cursor_line = content[..cursor_pos]
.bytes()
.filter(|&b| b == b'\n')
.count();
}
}
} }
app.previous_content = content.clone(); app.previous_content = content.to_owned();
app.previous_cursor_char_index = current_cursor_pos; app.previous_cursor_char_index = current_cursor_pos;
if let Some(active_tab) = app.get_active_tab_mut() {
active_tab.last_content_hash =
crate::app::tab::compute_content_hash(&active_tab.content, &mut active_tab.hasher);
}
} }
// Check if font settings changed and trigger reprocessing if app.font_settings_changed || app.text_needs_processing {
if app.font_settings_changed {
if let Some(active_tab) = app.get_active_tab() { if let Some(active_tab) = app.get_active_tab() {
let content = active_tab.content.clone(); let content = active_tab.content.to_owned();
if !content.is_empty() {
app.process_text_for_rendering(&content, ui); app.process_text_for_rendering(&content, ui);
} }
}
app.font_settings_changed = false; app.font_settings_changed = false;
app.text_needs_processing = false;
} }
if !word_wrap { if !word_wrap {
@ -150,7 +226,7 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R
.text_styles .text_styles
.get(&egui::TextStyle::Monospace) .get(&egui::TextStyle::Monospace)
.unwrap_or(&egui::FontId::monospace(font_size)) .unwrap_or(&egui::FontId::monospace(font_size))
.clone(); .to_owned();
let line_height = ui.fonts(|fonts| fonts.row_height(&font_id)); 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 y_pos = output.response.rect.top() + (cursor_line as f32 * line_height);
@ -165,12 +241,10 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R
} }
} }
} }
app.previous_cursor_position = Some(cursor_pos); app.previous_cursor_position = Some(cursor_pos);
} }
} }
// Request focus if no dialogs are open
if !output.response.has_focus() if !output.response.has_focus()
&& !show_preferences && !show_preferences
&& !show_about && !show_about

View File

@ -1,11 +1,23 @@
use eframe::egui; use eframe::egui;
pub(super) fn _draw_find_highlight( /// Safely get a string slice up to a byte position, ensuring UTF-8 boundaries
fn safe_slice_to_pos(content: &str, pos: usize) -> &str {
let pos = pos.min(content.len());
let mut boundary_pos = pos;
while boundary_pos > 0 && !content.is_char_boundary(boundary_pos) {
boundary_pos -= 1;
}
&content[..boundary_pos]
}
pub(super) fn draw_find_highlights(
ui: &mut egui::Ui, ui: &mut egui::Ui,
content: &str, content: &str,
start_pos: usize, matches: &[(usize, usize)],
end_pos: usize, current_match_index: Option<usize>,
editor_rect: egui::Rect, galley: &std::sync::Arc<egui::Galley>,
text_area_left: f32,
text_area_top: f32,
font_size: f32, font_size: f32,
) { ) {
let font_id = ui let font_id = ui
@ -13,15 +25,47 @@ pub(super) fn _draw_find_highlight(
.text_styles .text_styles
.get(&egui::TextStyle::Monospace) .get(&egui::TextStyle::Monospace)
.unwrap_or(&egui::FontId::monospace(font_size)) .unwrap_or(&egui::FontId::monospace(font_size))
.clone(); .to_owned();
let text_up_to_start = &content[..start_pos.min(content.len())]; for (match_index, &(start_pos, end_pos)) in matches.iter().enumerate() {
let is_current_match = current_match_index == Some(match_index);
draw_single_highlight(
ui,
content,
start_pos,
end_pos,
text_area_left,
text_area_top,
galley,
&font_id,
is_current_match,
);
}
}
fn draw_single_highlight(
ui: &mut egui::Ui,
content: &str,
start_pos: usize,
end_pos: usize,
text_area_left: f32,
text_area_top: f32,
galley: &std::sync::Arc<egui::Galley>,
font_id: &egui::FontId,
is_current_match: bool,
) {
let text_up_to_start = safe_slice_to_pos(content, start_pos);
let start_line = text_up_to_start.chars().filter(|&c| c == '\n').count(); let start_line = text_up_to_start.chars().filter(|&c| c == '\n').count();
if start_line >= galley.rows.len() {
return;
}
let line_start_byte_pos = text_up_to_start.rfind('\n').map(|pos| pos + 1).unwrap_or(0); let line_start_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 line_start_char_pos = safe_slice_to_pos(content, line_start_byte_pos)
let start_char_pos = content[..start_pos].chars().count(); .chars()
.count();
let start_char_pos = safe_slice_to_pos(content, start_pos).chars().count();
let start_col = start_char_pos - line_start_char_pos; let start_col = start_char_pos - line_start_char_pos;
let lines: Vec<&str> = content.lines().collect(); let lines: Vec<&str> = content.lines().collect();
@ -32,18 +76,11 @@ pub(super) fn _draw_find_highlight(
let line_text = lines[start_line]; let line_text = lines[start_line];
let text_before_match: String = line_text.chars().take(start_col).collect(); 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| { let text_before_width = ui.fonts(|fonts| {
fonts fonts
.layout( .layout(
text_before_match, text_before_match,
font_id.clone(), font_id.to_owned(),
egui::Color32::WHITE, egui::Color32::WHITE,
f32::INFINITY, f32::INFINITY,
) )
@ -51,17 +88,17 @@ pub(super) fn _draw_find_highlight(
.x .x
}); });
let start_y = text_area_top + (start_line as f32 * line_height); let galley_row = &galley.rows[start_line];
let start_y = text_area_top + galley_row.min_y();
let line_height = galley_row.height();
let start_x = text_area_left + text_before_width; let start_x = text_area_left + text_before_width;
{
let match_text = &content[start_pos..end_pos.min(content.len())]; let match_text = &content[start_pos..end_pos.min(content.len())];
let match_width = ui.fonts(|fonts| { let match_width = ui.fonts(|fonts| {
fonts fonts
.layout( .layout(
match_text.to_string(), match_text.to_string(),
font_id.clone(), font_id.to_owned(),
ui.visuals().text_color(), ui.visuals().text_color(),
f32::INFINITY, f32::INFINITY,
) )
@ -74,7 +111,12 @@ pub(super) fn _draw_find_highlight(
egui::vec2(match_width, line_height), egui::vec2(match_width, line_height),
); );
ui.painter() let highlight_color = if is_current_match {
.rect_filled(highlight_rect, 0.0, ui.visuals().selection.bg_fill); ui.visuals().selection.bg_fill
} } else {
ui.visuals().selection.bg_fill.gamma_multiply(0.6)
};
let painter = ui.painter();
painter.rect_filled(highlight_rect, 0.0, highlight_color);
} }

View File

@ -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
}
}

View File

@ -29,7 +29,7 @@ pub(super) fn get_visual_line_mapping(
cache cache
.borrow() .borrow()
.as_ref() .as_ref()
.map(|(_, _, mapping)| mapping.clone()) .map(|(_, _, mapping)| mapping.to_owned())
.unwrap_or_default() .unwrap_or_default()
}) })
} }
@ -52,7 +52,7 @@ fn calculate_visual_line_mapping(
let galley = ui.fonts(|fonts| { let galley = ui.fonts(|fonts| {
fonts.layout( fonts.layout(
line.to_string(), line.to_string(),
font_id.clone(), font_id.to_owned(),
egui::Color32::WHITE, egui::Color32::WHITE,
available_width, available_width,
) )
@ -100,7 +100,7 @@ pub(super) fn render_line_numbers(
}; };
ui.label( ui.label(
egui::RichText::new(text) egui::RichText::new(text)
.font(font_id.clone()) .font(font_id.to_owned())
.color(text_color), .color(text_color),
); );
} }
@ -109,7 +109,7 @@ pub(super) fn render_line_numbers(
let text = format!("{:>width$}", i, width = line_count_width); let text = format!("{:>width$}", i, width = line_count_width);
ui.label( ui.label(
egui::RichText::new(text) egui::RichText::new(text)
.font(font_id.clone()) .font(font_id.to_owned())
.color(text_color), .color(text_color),
); );
} }

26
src/ui/constants.rs Normal file
View File

@ -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;

View File

@ -1,4 +1,5 @@
use crate::app::TextEditor; use crate::app::TextEditor;
use crate::ui::constants::*;
use eframe::egui; use eframe::egui;
pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) { pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) {
@ -6,29 +7,58 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) {
let mut should_close = false; let mut should_close = false;
let mut query_changed = false; let mut query_changed = false;
let mut should_focus_editor = false;
egui::Window::new("Find") let just_opened = app.show_find && !app.prev_show_find;
if just_opened && !app.find_query.is_empty() {
app.update_find_matches();
if app.current_match_index.is_some() {
app.select_current_match(ctx);
app.should_select_current_match = true;
}
}
let focus_requested = ctx.memory(|mem| {
mem.data
.get_temp::<bool>(egui::Id::new("focus_find_input"))
.unwrap_or(false)
});
let top_right_pos = egui::Pos2::new(ctx.available_rect().right(), 22.0);
egui::Window::new("")
.collapsible(false) .collapsible(false)
.resizable(false) .resizable(false)
.movable(true) .movable(true)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .title_bar(false)
.default_pos(top_right_pos)
.fade_in(true)
.fade_out(true)
.frame(egui::Frame { .frame(egui::Frame {
fill: visuals.window_fill, fill: visuals.window_fill,
stroke: visuals.window_stroke, stroke: visuals.window_stroke,
corner_radius: egui::CornerRadius::same(8), corner_radius: egui::CornerRadius::same(CORNER_RADIUS),
shadow: visuals.window_shadow, shadow: visuals.window_shadow,
inner_margin: egui::Margin::same(16), inner_margin: egui::Margin::same(INNER_MARGIN),
outer_margin: egui::Margin::same(0), outer_margin: egui::Margin::same(0),
}) })
.show(ctx, |ui| { .show(ctx, |ui| {
ui.vertical(|ui| { ui.vertical(|ui| {
ui.set_min_width(300.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
let arrow_text = if app.show_replace_section {
""
} else {
""
};
if ui.button(arrow_text).clicked() {
app.show_replace_section = !app.show_replace_section;
}
ui.label("Find:"); ui.label("Find:");
let response = ui.add( let response = ui.add(
egui::TextEdit::singleline(&mut app.find_query) egui::TextEdit::singleline(&mut app.find_query)
.desired_width(200.0) .desired_width(250.0)
.hint_text("Enter search text..."), .hint_text("Enter search text..."),
); );
@ -36,17 +66,30 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) {
query_changed = true; query_changed = true;
} }
if !response.has_focus() { if just_opened || focus_requested || app.focus_find {
response.request_focus(); response.request_focus();
app.focus_find = false;
} }
if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
app.find_next(); app.find_next(ctx);
response.request_focus(); response.request_focus();
} }
}); });
ui.add_space(8.0); if app.show_replace_section {
ui.horizontal(|ui| {
ui.add_space(SMALL);
ui.label("Replace:");
let _replace_response = ui.add(
egui::TextEdit::singleline(&mut app.replace_query)
.desired_width(250.0)
.hint_text("Enter replacement text..."),
);
});
}
ui.add_space(MEDIUM);
ui.horizontal(|ui| { ui.horizontal(|ui| {
let case_sensitive_changed = ui let case_sensitive_changed = ui
@ -55,9 +98,27 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) {
if case_sensitive_changed { if case_sensitive_changed {
query_changed = true; query_changed = true;
} }
if app.show_replace_section {
ui.add_space(MEDIUM);
let replace_current_enabled =
!app.find_matches.is_empty() && app.current_match_index.is_some();
ui.add_enabled_ui(replace_current_enabled, |ui| {
if ui.button("Replace").clicked() {
app.replace_current_match(ctx);
}
}); });
ui.add_space(8.0); let replace_all_enabled = !app.find_matches.is_empty();
ui.add_enabled_ui(replace_all_enabled, |ui| {
if ui.button("Replace All").clicked() {
app.replace_all(ctx);
}
});
}
});
ui.add_space(MEDIUM);
ui.horizontal(|ui| { ui.horizontal(|ui| {
let match_text = if app.find_matches.is_empty() { let match_text = if app.find_matches.is_empty() {
@ -75,23 +136,23 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) {
ui.label(egui::RichText::new(match_text).weak()); ui.label(egui::RichText::new(match_text).weak());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("").clicked() { if ui.button("").clicked() {
should_close = true; should_close = true;
} }
ui.add_space(4.0); ui.add_space(SMALL);
let next_enabled = !app.find_matches.is_empty(); let next_enabled = !app.find_matches.is_empty();
ui.add_enabled_ui(next_enabled, |ui| { ui.add_enabled_ui(next_enabled, |ui| {
if ui.button("Next").clicked() { if ui.button("Next").clicked() {
app.find_next(); app.find_next(ctx);
} }
}); });
let prev_enabled = !app.find_matches.is_empty(); let prev_enabled = !app.find_matches.is_empty();
ui.add_enabled_ui(prev_enabled, |ui| { ui.add_enabled_ui(prev_enabled, |ui| {
if ui.button("Previous").clicked() { if ui.button("Previous").clicked() {
app.find_previous(); app.find_previous(ctx);
} }
}); });
}); });
@ -101,21 +162,28 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) {
if query_changed { if query_changed {
app.update_find_matches(); app.update_find_matches();
if app.current_match_index.is_some() {
app.select_current_match(ctx);
app.should_select_current_match = true;
}
} }
if should_close { if should_close {
app.select_current_match(ctx);
app.should_select_current_match = true;
app.show_find = false; app.show_find = false;
} }
ctx.input(|i| { ctx.input(|i| {
if i.key_pressed(egui::Key::Escape) { if i.key_pressed(egui::Key::Enter) && i.modifiers.ctrl && app.show_find {
app.show_find = false; should_focus_editor = true;
} else if i.key_pressed(egui::Key::F3) { app.should_select_current_match = true;
if i.modifiers.shift {
app.find_previous();
} else {
app.find_next();
}
} }
}); });
if should_focus_editor {
ctx.memory_mut(|mem| {
mem.request_focus(egui::Id::new("main_text_editor"));
});
}
} }

View File

@ -1,5 +1,6 @@
use crate::{app::TextEditor, io}; use crate::{app::TextEditor, io};
use eframe::egui::{self, Frame}; use eframe::egui::{self, Frame};
use egui::UiKind;
pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
let now = std::time::Instant::now(); let now = std::time::Instant::now();
@ -43,34 +44,34 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
} }
} }
egui::menu::bar(ui, |ui| { egui::MenuBar::new().ui(ui, |ui| {
ui.menu_button("File", |ui| { ui.menu_button("File", |ui| {
app.menu_interaction_active = true; app.menu_interaction_active = true;
if ui.button("New").clicked() { if ui.button("New").clicked() {
io::new_file(app); io::new_file(app);
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
if ui.button("Open...").clicked() { if ui.button("Open...").clicked() {
io::open_file(app); io::open_file(app);
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
ui.separator(); ui.separator();
if ui.button("Save").clicked() { if ui.button("Save").clicked() {
io::save_file(app); io::save_file(app);
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
if ui.button("Save As...").clicked() { if ui.button("Save As...").clicked() {
io::save_as_file(app); io::save_as_file(app);
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
ui.separator(); ui.separator();
if ui.button("Preferences").clicked() { if ui.button("Preferences").clicked() {
app.show_preferences = true; app.show_preferences = true;
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
if ui.button("Exit").clicked() { if ui.button("Exit").clicked() {
app.request_quit(ctx); app.request_quit(ctx);
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
}); });
@ -78,16 +79,16 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
app.menu_interaction_active = true; app.menu_interaction_active = true;
if ui.button("Cut").clicked() { if ui.button("Cut").clicked() {
ui.ctx().input_mut(|i| i.events.push(egui::Event::Cut)); ui.ctx().input_mut(|i| i.events.push(egui::Event::Cut));
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
if ui.button("Copy").clicked() { if ui.button("Copy").clicked() {
ui.ctx().input_mut(|i| i.events.push(egui::Event::Copy)); ui.ctx().input_mut(|i| i.events.push(egui::Event::Copy));
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
if ui.button("Paste").clicked() { if ui.button("Paste").clicked() {
ui.ctx() ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::RequestPaste); .send_viewport_cmd(egui::ViewportCommand::RequestPaste);
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
if ui.button("Delete").clicked() { if ui.button("Delete").clicked() {
ui.ctx().input_mut(|i| { ui.ctx().input_mut(|i| {
@ -99,7 +100,7 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
modifiers: egui::Modifiers::NONE, modifiers: egui::Modifiers::NONE,
}) })
}); });
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
if ui.button("Select All").clicked() { if ui.button("Select All").clicked() {
let text_edit_id = egui::Id::new("main_text_editor"); let text_edit_id = egui::Id::new("main_text_editor");
@ -116,7 +117,7 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); egui::TextEdit::store_state(ui.ctx(), text_edit_id, state);
} }
} }
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
ui.separator(); ui.separator();
if ui.button("Undo").clicked() { if ui.button("Undo").clicked() {
@ -127,21 +128,24 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
if let Some(active_tab) = app.get_active_tab_mut() { if let Some(active_tab) = app.get_active_tab_mut() {
let current_state = ( let current_state = (
state.cursor.char_range().unwrap_or_default(), state.cursor.char_range().unwrap_or_default(),
active_tab.content.clone(), active_tab.content.to_string(),
); );
let mut undoer = state.undoer(); let mut undoer = state.undoer();
if let Some((cursor_range, content)) = if let Some((cursor_range, content)) =
undoer.undo(&current_state) undoer.undo(&current_state)
{ {
active_tab.content = content.clone(); active_tab.content = content.to_string();
state.cursor.set_char_range(Some(*cursor_range)); state.cursor.set_char_range(Some(*cursor_range));
state.set_undoer(undoer); state.set_undoer(undoer);
egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); egui::TextEdit::store_state(ui.ctx(), text_edit_id, state);
active_tab.update_modified_state(); active_tab.update_modified_state();
if app.show_find && !app.find_query.is_empty() {
app.update_find_matches();
} }
} }
} }
ui.close_menu(); }
ui.close_kind(UiKind::Menu);
} }
if ui.button("Redo").clicked() { if ui.button("Redo").clicked() {
let text_edit_id = egui::Id::new("main_text_editor"); let text_edit_id = egui::Id::new("main_text_editor");
@ -151,21 +155,24 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
if let Some(active_tab) = app.get_active_tab_mut() { if let Some(active_tab) = app.get_active_tab_mut() {
let current_state = ( let current_state = (
state.cursor.char_range().unwrap_or_default(), state.cursor.char_range().unwrap_or_default(),
active_tab.content.clone(), active_tab.content.to_string(),
); );
let mut undoer = state.undoer(); let mut undoer = state.undoer();
if let Some((cursor_range, content)) = if let Some((cursor_range, content)) =
undoer.redo(&current_state) undoer.redo(&current_state)
{ {
active_tab.content = content.clone(); active_tab.content = content.to_string();
state.cursor.set_char_range(Some(*cursor_range)); state.cursor.set_char_range(Some(*cursor_range));
state.set_undoer(undoer); state.set_undoer(undoer);
egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); egui::TextEdit::store_state(ui.ctx(), text_edit_id, state);
active_tab.update_modified_state(); active_tab.update_modified_state();
if app.show_find && !app.find_query.is_empty() {
app.update_find_matches();
} }
} }
} }
ui.close_menu(); }
ui.close_kind(UiKind::Menu);
} }
}); });
@ -176,25 +183,29 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
.clicked() .clicked()
{ {
app.save_config(); app.save_config();
ui.close_menu(); ui.close_kind(UiKind::Menu);
}
if ui.checkbox(&mut app.word_wrap, "Word Wrap").clicked() {
app.save_config();
ui.close_menu();
} }
if ui if ui
.checkbox(&mut app.auto_hide_tab_bar, "Hide Tab Bar") .checkbox(&mut app.syntax_highlighting, "Syntax Highlighting")
.clicked() .clicked()
{ {
app.save_config(); app.save_config();
ui.close_menu(); ui.close_kind(UiKind::Menu);
}
if ui.checkbox(&mut app.word_wrap, "Word Wrap").clicked() {
app.save_config();
ui.close_kind(UiKind::Menu);
}
if ui.checkbox(&mut app.hide_tab_bar, "Hide Tab Bar").clicked() {
app.save_config();
ui.close_kind(UiKind::Menu);
} }
if ui if ui
.checkbox(&mut app.auto_hide_toolbar, "Auto Hide Toolbar") .checkbox(&mut app.auto_hide_toolbar, "Auto Hide Toolbar")
.clicked() .clicked()
{ {
app.save_config(); app.save_config();
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
ui.separator(); ui.separator();
@ -202,7 +213,7 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
if ui.button("Reset Zoom").clicked() { if ui.button("Reset Zoom").clicked() {
app.zoom_factor = 1.0; app.zoom_factor = 1.0;
ctx.set_zoom_factor(1.0); ctx.set_zoom_factor(1.0);
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
ui.separator(); ui.separator();
@ -222,7 +233,7 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
if current_theme != crate::app::theme::Theme::System { if current_theme != crate::app::theme::Theme::System {
app.set_theme(ctx); app.set_theme(ctx);
} }
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
if ui if ui
.radio_value( .radio_value(
@ -235,7 +246,7 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
if current_theme != crate::app::theme::Theme::Light { if current_theme != crate::app::theme::Theme::Light {
app.set_theme(ctx); app.set_theme(ctx);
} }
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
if ui if ui
.radio_value(&mut app.theme, crate::app::theme::Theme::Dark, "Dark") .radio_value(&mut app.theme, crate::app::theme::Theme::Dark, "Dark")
@ -244,16 +255,16 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
if current_theme != crate::app::theme::Theme::Dark { if current_theme != crate::app::theme::Theme::Dark {
app.set_theme(ctx); app.set_theme(ctx);
} }
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
ui.separator(); ui.separator();
if ui.radio_value(&mut app.line_side, false, "Left").clicked() { if ui.radio_value(&mut app.line_side, false, "Left").clicked() {
app.save_config(); app.save_config();
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
if ui.radio_value(&mut app.line_side, true, "Right").clicked() { if ui.radio_value(&mut app.line_side, true, "Right").clicked() {
app.save_config(); app.save_config();
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
}); });
}); });
@ -262,30 +273,24 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
app.menu_interaction_active = true; app.menu_interaction_active = true;
if ui.button("Shortcuts").clicked() { if ui.button("Shortcuts").clicked() {
app.show_shortcuts = true; app.show_shortcuts = true;
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
if ui.button("About").clicked() { if ui.button("About").clicked() {
app.show_about = true; app.show_about = true;
ui.close_menu(); ui.close_kind(UiKind::Menu);
} }
}); });
if app.auto_hide_tab_bar { if app.hide_tab_bar {
let tab_title = if let Some(tab) = app.get_active_tab() { let tab_title = if let Some(tab) = app.get_active_tab() {
tab.title.clone() tab.get_display_title()
} else { } else {
let empty_tab = crate::app::tab::Tab::new_empty(1); let empty_tab = crate::app::tab::Tab::new_empty(1);
empty_tab.title.clone() empty_tab.get_display_title()
}; };
let window_width = ctx.screen_rect().width(); let window_width = ctx.screen_rect().width();
let font_id = ui.style().text_styles[&egui::TextStyle::Body].clone(); 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| { let text_galley = ui.fonts(|fonts| {
fonts.layout_job(egui::text::LayoutJob::simple_singleline( fonts.layout_job(egui::text::LayoutJob::simple_singleline(

View File

@ -1,11 +1,14 @@
use crate::app::TextEditor; use crate::app::TextEditor;
use crate::ui::constants::*;
use eframe::egui; use eframe::egui;
pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
let visuals = &ctx.style().visuals; let visuals = &ctx.style().visuals;
let screen_rect = ctx.screen_rect(); let screen_rect = ctx.screen_rect();
let window_width = (screen_rect.width() * 0.6).min(400.0).max(300.0); let window_width =
let window_height = (screen_rect.height() * 0.7).min(500.0).max(250.0); (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); let max_size = egui::Vec2::new(window_width, window_height);
egui::Window::new("Preferences") egui::Window::new("Preferences")
@ -14,23 +17,91 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.default_open(true) .default_open(true)
.max_size(max_size) .max_size(max_size)
.fade_in(true)
.fade_out(true)
.frame(egui::Frame { .frame(egui::Frame {
fill: visuals.window_fill, fill: visuals.window_fill,
stroke: visuals.window_stroke, stroke: visuals.window_stroke,
corner_radius: egui::CornerRadius::same(8), corner_radius: egui::CornerRadius::same(CORNER_RADIUS),
shadow: visuals.window_shadow, shadow: visuals.window_shadow,
inner_margin: egui::Margin::same(16), inner_margin: egui::Margin::same(INNER_MARGIN),
outer_margin: egui::Margin::same(0), outer_margin: egui::Margin::same(0),
}) })
.show(ctx, |ui| { .show(ctx, |ui| {
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
ui.heading("Font Settings"); ui.heading("Editor Settings");
ui.add_space(8.0); ui.add_space(MEDIUM);
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Font Family:"); ui.vertical(|ui| {
ui.add_space(5.0); 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(SMALL);
ui.separator();
ui.add_space(LARGE);
ui.heading("Font Settings");
ui.add_space(MEDIUM);
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label("Font Family:");
ui.add_space(SMALL);
ui.label("Font Size:");
});
ui.vertical(|ui| {
let mut changed = false; let mut changed = false;
egui::ComboBox::from_id_salt("font_family") egui::ComboBox::from_id_salt("font_family")
.selected_text(&app.font_family) .selected_text(&app.font_family)
@ -57,17 +128,6 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
} }
}); });
if changed {
app.apply_font_settings_with_ui(ctx, ui);
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("Font Size:");
ui.add_space(5.0);
if app.font_size_input.is_none() { if app.font_size_input.is_none() {
app.font_size_input = Some(app.font_size.to_string()); app.font_size_input = Some(app.font_size.to_string());
} }
@ -75,16 +135,18 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
let mut font_size_text = app let mut font_size_text = app
.font_size_input .font_size_input
.as_ref() .as_ref()
.unwrap_or(&"14".to_string()) .unwrap_or(&DEFAULT_FONT_SIZE_STR.to_string())
.clone(); .to_owned();
ui.add_space(SMALL);
ui.horizontal(|ui| {
let response = ui.add( let response = ui.add(
egui::TextEdit::singleline(&mut font_size_text) egui::TextEdit::singleline(&mut font_size_text)
.desired_width(50.0) .desired_width(FONT_SIZE_INPUT_WIDTH)
.hint_text("14") .hint_text(DEFAULT_FONT_SIZE_STR)
.id(egui::Id::new("font_size_input")), .id(egui::Id::new("font_size_input")),
); );
app.font_size_input = Some(font_size_text.clone()); app.font_size_input = Some(font_size_text.to_owned());
if response.clicked() { if response.clicked() {
response.request_focus(); response.request_focus();
@ -94,30 +156,34 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
if response.lost_focus() { if response.lost_focus() {
if let Ok(new_size) = font_size_text.parse::<f32>() { if let Ok(new_size) = font_size_text.parse::<f32>() {
let clamped_size = new_size.clamp(8.0, 32.0); let clamped_size = new_size.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE);
if (app.font_size - clamped_size).abs() > 0.1 { if (app.font_size - clamped_size).abs() > 0.1 {
app.font_size = clamped_size; app.font_size = clamped_size;
app.apply_font_settings_with_ui(ctx, ui); app.apply_font_settings(ctx);
} }
} }
app.font_size_input = None; app.font_size_input = None;
} }
if changed {
app.apply_font_settings(ctx);
}
})
});
}); });
ui.add_space(12.0); ui.add_space(MEDIUM);
ui.separator();
ui.add_space(8.0);
ui.label("Preview:"); ui.label("Preview:");
ui.add_space(4.0); ui.add_space(SMALL);
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.max_height(150.0) .max_height(PREVIEW_AREA_MAX_HEIGHT)
.show(ui, |ui| { .show(ui, |ui| {
egui::Frame::new() egui::Frame::new()
.fill(visuals.code_bg_color) .fill(visuals.code_bg_color)
.stroke(visuals.widgets.noninteractive.bg_stroke) .stroke(visuals.widgets.noninteractive.bg_stroke)
.inner_margin(egui::Margin::same(8)) .inner_margin(egui::Margin::same(INNER_MARGIN))
.show(ui, |ui| { .show(ui, |ui| {
let preview_font = egui::FontId::new( let preview_font = egui::FontId::new(
app.font_size, app.font_size,
@ -130,23 +196,24 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
egui::RichText::new( egui::RichText::new(
"The quick brown fox jumps over the lazy dog.", "The quick brown fox jumps over the lazy dog.",
) )
.font(preview_font.clone()), .font(preview_font.to_owned()),
); );
ui.label( ui.label(
egui::RichText::new("ABCDEFGHIJKLMNOPQRSTUVWXYZ") egui::RichText::new("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
.font(preview_font.clone()), .font(preview_font.to_owned()),
); );
ui.label( ui.label(
egui::RichText::new("abcdefghijklmnopqrstuvwxyz") egui::RichText::new("abcdefghijklmnopqrstuvwxyz")
.font(preview_font.clone()), .font(preview_font.to_owned()),
); );
ui.label( ui.label(
egui::RichText::new("1234567890 !@#$%^&*()").font(preview_font), egui::RichText::new("1234567890 !@#$%^&*()")
.font(preview_font.to_owned()),
); );
}); });
}); });
ui.add_space(12.0); ui.add_space(LARGE);
if ui.button("Close").clicked() { if ui.button("Close").clicked() {
app.show_preferences = false; app.show_preferences = false;

View File

@ -1,39 +1,48 @@
use crate::app::TextEditor; use crate::app::TextEditor;
use crate::ui::constants::*;
use eframe::egui; use eframe::egui;
fn render_shortcuts_content(ui: &mut egui::Ui) { fn render_shortcuts_content(ui: &mut egui::Ui) {
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
ui.label(egui::RichText::new("Navigation").size(18.0).strong()); ui.label(
ui.label(egui::RichText::new("Ctrl + N: New").size(14.0)); egui::RichText::new("Navigation")
ui.label(egui::RichText::new("Ctrl + O: Open").size(14.0)); .size(UI_HEADER_SIZE)
ui.label(egui::RichText::new("Ctrl + S: Save").size(14.0)); .strong(),
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 + N: New").size(UI_TEXT_SIZE));
ui.label(egui::RichText::new("Ctrl + Tab: Next Tab").size(14.0)); ui.label(egui::RichText::new("Ctrl + O: Open").size(UI_TEXT_SIZE));
ui.label(egui::RichText::new("Ctrl + Shift + Tab: Last Tab").size(14.0)); ui.label(egui::RichText::new("Ctrl + S: Save").size(UI_TEXT_SIZE));
ui.add_space(16.0); 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.separator();
ui.label(egui::RichText::new("Editing").size(18.0).strong()); ui.label(egui::RichText::new("Editing").size(UI_HEADER_SIZE).strong());
ui.label(egui::RichText::new("Ctrl + Z: Undo").size(14.0)); ui.label(egui::RichText::new("Ctrl + Z: Undo").size(UI_TEXT_SIZE));
ui.label(egui::RichText::new("Ctrl + Shift + Z: Redo").size(14.0)); ui.label(egui::RichText::new("Ctrl + Shift + Z: Redo").size(UI_TEXT_SIZE));
ui.label(egui::RichText::new("Ctrl + X: Cut").size(14.0)); ui.label(egui::RichText::new("Ctrl + X: Cut").size(UI_TEXT_SIZE));
ui.label(egui::RichText::new("Ctrl + C: Copy").size(14.0)); ui.label(egui::RichText::new("Ctrl + C: Copy").size(UI_TEXT_SIZE));
ui.label(egui::RichText::new("Ctrl + V: Paste").size(14.0)); ui.label(egui::RichText::new("Ctrl + V: Paste").size(UI_TEXT_SIZE));
ui.label(egui::RichText::new("Ctrl + A: Select All").size(14.0)); ui.label(egui::RichText::new("Ctrl + A: Select All").size(UI_TEXT_SIZE));
ui.label(egui::RichText::new("Ctrl + D: Delete Line").size(14.0)); 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.separator();
ui.label(egui::RichText::new("Views").size(18.0).strong()); ui.label(egui::RichText::new("Views").size(UI_HEADER_SIZE).strong());
ui.label(egui::RichText::new("Ctrl + L: Toggle Line Numbers").size(14.0)); ui.label(egui::RichText::new("Ctrl + L: Toggle Line Numbers").size(UI_TEXT_SIZE));
ui.label(egui::RichText::new("Ctrl + Shift + L: Change Line Number Side").size(14.0)); ui.label(
ui.label(egui::RichText::new("Ctrl + K: Toggle Word Wrap").size(14.0)); egui::RichText::new("Ctrl + Shift + L: Change Line Number Side").size(UI_TEXT_SIZE),
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 + K: Toggle Word Wrap").size(UI_TEXT_SIZE));
ui.label(egui::RichText::new("Ctrl + =/-: Increase/Decrease Font Size").size(14.0)); ui.label(egui::RichText::new("Ctrl + H: Toggle Auto Hide Toolbar").size(UI_TEXT_SIZE));
ui.label(egui::RichText::new("Ctrl + Shift + =/-: Zoom In/Out").size(14.0)); 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( // ui.label(
// egui::RichText::new("Ctrl + Shift + .: Toggle Vim Mode") // egui::RichText::new("Ctrl + Shift + .: Toggle Vim Mode")
// .size(14.0) // .size(14.0)
@ -42,7 +51,8 @@ fn render_shortcuts_content(ui: &mut egui::Ui) {
// egui::RichText::new("Ctrl + .: Toggle Vim Mode") // egui::RichText::new("Ctrl + .: Toggle Vim Mode")
// .size(14.0) // .size(14.0)
// ); // );
ui.add_space(12.0); ui.add_space(VLARGE);
ui.separator();
}); });
} }
@ -50,27 +60,29 @@ pub(crate) fn shortcuts_window(app: &mut TextEditor, ctx: &egui::Context) {
let visuals = &ctx.style().visuals; let visuals = &ctx.style().visuals;
let screen_rect = ctx.screen_rect(); let screen_rect = ctx.screen_rect();
// Calculate appropriate window size that always fits nicely in the main window let window_width =
let window_width = (screen_rect.width() * 0.6).clamp(300.0, 400.0); (screen_rect.width() * WINDOW_WIDTH_RATIO).clamp(WINDOW_MIN_WIDTH, WINDOW_MAX_WIDTH);
let window_height = (screen_rect.height() * 0.7).clamp(250.0, 500.0); let window_height =
(screen_rect.height() * WINDOW_HEIGHT_RATIO).clamp(WINDOW_MIN_HEIGHT, WINDOW_MAX_HEIGHT);
egui::Window::new("Shortcuts") egui::Window::new("Shortcuts")
.collapsible(false) .collapsible(false)
.resizable(false) .resizable(false)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.fixed_size([window_width, window_height]) .fixed_size([window_width, window_height])
.fade_in(true)
.fade_out(true)
.frame(egui::Frame { .frame(egui::Frame {
fill: visuals.window_fill, fill: visuals.window_fill,
stroke: visuals.window_stroke, stroke: visuals.window_stroke,
corner_radius: egui::CornerRadius::same(8), corner_radius: egui::CornerRadius::same(CORNER_RADIUS),
shadow: visuals.window_shadow, shadow: visuals.window_shadow,
inner_margin: egui::Margin::same(16), inner_margin: egui::Margin::same(INNER_MARGIN),
outer_margin: egui::Margin::same(0), outer_margin: egui::Margin::same(0),
}) })
.show(ctx, |ui| { .show(ctx, |ui| {
ui.vertical(|ui| { ui.vertical(|ui| {
// Scrollable content area let available_height = ui.available_height() - 40.0;
let available_height = ui.available_height() - 40.0; // Reserve space for close button
ui.allocate_ui_with_layout( ui.allocate_ui_with_layout(
[ui.available_width(), available_height].into(), [ui.available_width(), available_height].into(),
egui::Layout::top_down(egui::Align::Center), egui::Layout::top_down(egui::Align::Center),
@ -83,9 +95,8 @@ pub(crate) fn shortcuts_window(app: &mut TextEditor, ctx: &egui::Context) {
}, },
); );
// Fixed close button at bottom
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
ui.add_space(8.0); ui.add_space(MEDIUM);
let visuals = ui.visuals(); let visuals = ui.visuals();
let close_button = egui::Button::new("Close") let close_button = egui::Button::new("Close")
.fill(visuals.widgets.inactive.bg_fill) .fill(visuals.widgets.inactive.bg_fill)

View File

@ -3,9 +3,14 @@ use eframe::egui::{self, Frame};
pub(crate) fn tab_bar(app: &mut TextEditor, ctx: &egui::Context) { pub(crate) fn tab_bar(app: &mut TextEditor, ctx: &egui::Context) {
let frame = Frame::NONE.fill(ctx.style().visuals.panel_fill); 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) .frame(frame)
.show(ctx, |ui| { .show(ctx, |ui| {
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| { ui.horizontal(|ui| {
let mut tab_to_close_unmodified = None; let mut tab_to_close_unmodified = None;
let mut tab_to_close_modified = None; let mut tab_to_close_modified = None;
@ -34,8 +39,11 @@ pub(crate) fn tab_bar(app: &mut TextEditor, ctx: &egui::Context) {
label_text = label_text.italics(); label_text = label_text.italics();
} }
let tab_response = let tab_response = ui.add(
ui.add(egui::Label::new(label_text).sense(egui::Sense::click())); egui::Label::new(label_text)
.selectable(false)
.sense(egui::Sense::click()),
);
if tab_response.clicked() { if tab_response.clicked() {
tab_to_switch = Some(i); tab_to_switch = Some(i);
} }
@ -45,7 +53,10 @@ pub(crate) fn tab_bar(app: &mut TextEditor, ctx: &egui::Context) {
let close_button = egui::Button::new("×") let close_button = egui::Button::new("×")
.small() .small()
.fill(visuals.panel_fill) .fill(visuals.panel_fill)
.stroke(egui::Stroke::new(0.0, egui::Color32::from_rgb(0, 0, 0))); .stroke(egui::Stroke::new(
0.0,
egui::Color32::from_rgb(0, 0, 0),
));
let close_response = ui.add(close_button); let close_response = ui.add(close_button);
if close_response.clicked() { if close_response.clicked() {
if *is_modified { if *is_modified {
@ -83,6 +94,7 @@ pub(crate) fn tab_bar(app: &mut TextEditor, ctx: &egui::Context) {
} }
}); });
}); });
});
app.tab_bar_rect = Some(response.response.rect); app.tab_bar_rect = Some(tab_bar.response.rect);
} }