diff --git a/Cargo.toml b/Cargo.toml index 63ab481..7610905 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "ced" -version = "0.1.3" +version = "0.2.0" edition = "2024" [dependencies] -eframe = "0.32" -egui = "0.32" -egui_extras = { version = "0.32", features = ["syntect"] } +eframe = "0.33.3" +egui = "0.33.3" +egui_extras = { version = "0.33.3", features = ["syntect"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.141" rfd = "0.15.4" @@ -17,3 +17,4 @@ syntect = "5.2.0" plist = "1.7.4" diffy = "0.4.2" uuid = { version = "1.0", features = ["v4"] } +egui_commonmark = { version = "0.22" } \ No newline at end of file diff --git a/src/app/shortcuts.rs b/src/app/shortcuts.rs index 294a66b..d327a85 100644 --- a/src/app/shortcuts.rs +++ b/src/app/shortcuts.rs @@ -14,8 +14,10 @@ enum ShortcutAction { ToggleLineSide, ToggleWordWrap, ToggleAutoHideToolbar, + ToggleBottomBar, ToggleFind, ToggleReplace, + ToggleMarkdown, FocusFind, NextTab, PrevTab, @@ -92,6 +94,11 @@ fn get_shortcuts() -> Vec { egui::Key::H, ShortcutAction::ToggleAutoHideToolbar, ), + ( + egui::Modifiers::CTRL, + egui::Key::B, + ShortcutAction::ToggleBottomBar, + ), ( egui::Modifiers::CTRL | egui::Modifiers::SHIFT, egui::Key::Tab, @@ -152,6 +159,11 @@ fn get_shortcuts() -> Vec { egui::Key::Escape, ShortcutAction::Escape, ), + ( + egui::Modifiers::CTRL, + egui::Key::M, + ShortcutAction::ToggleMarkdown, + ), ] } @@ -211,6 +223,11 @@ fn execute_action(action: ShortcutAction, editor: &mut TextEditor) -> bool { editor.save_config(); false } + ShortcutAction::ToggleBottomBar => { + editor.hide_bottom_bar = !editor.hide_bottom_bar; + editor.save_config(); + false + } ShortcutAction::NextTab => { let next_tab_index = editor.active_tab_index + 1; if next_tab_index < editor.tabs.len() { @@ -228,8 +245,9 @@ fn execute_action(action: ShortcutAction, editor: &mut TextEditor) -> bool { } false } - ShortcutAction::PageUp => false, - ShortcutAction::PageDown => false, + ShortcutAction::PageUp | ShortcutAction::PageDown => { + false + } ShortcutAction::ZoomIn => { editor.font_size += 1.0; true @@ -293,12 +311,18 @@ fn execute_action(action: ShortcutAction, editor: &mut TextEditor) -> bool { editor.show_preferences = !editor.show_preferences; false } + ShortcutAction::ToggleMarkdown => { + editor.show_markdown = !editor.show_markdown; + false + } } } pub fn handle(editor: &mut TextEditor, ctx: &egui::Context) { let mut font_zoom_occurred = false; let mut global_zoom_occurred = false; + let mut page_up_pressed = false; + let mut page_down_pressed = false; ctx.input_mut(|i| { for (modifiers, key, action) in get_shortcuts() { @@ -313,6 +337,12 @@ pub fn handle(editor: &mut TextEditor, ctx: &egui::Context) { execute_action(action, editor); global_zoom_occurred = true; } + ShortcutAction::PageUp => { + page_up_pressed = true; + } + ShortcutAction::PageDown => { + page_down_pressed = true; + } _ => { execute_action(action, editor); } @@ -330,6 +360,14 @@ pub fn handle(editor: &mut TextEditor, ctx: &egui::Context) { ctx.set_zoom_factor(editor.zoom_factor); } + if page_up_pressed { + editor.handle_page_movement(ctx, false); + } + + if page_down_pressed { + editor.handle_page_movement(ctx, true); + } + if editor.should_select_current_match { editor.select_current_match(ctx); editor.should_select_current_match = false; diff --git a/src/app/state/app_impl.rs b/src/app/state/app_impl.rs index 441992d..8625762 100644 --- a/src/app/state/app_impl.rs +++ b/src/app/state/app_impl.rs @@ -1,6 +1,7 @@ use super::editor::TextEditor; use crate::app::shortcuts; use crate::ui::about_window::about_window; +use crate::ui::bottom_bar::bottom_bar; use crate::ui::central_panel::central_panel; use crate::ui::find_window::find_window; use crate::ui::menu_bar::menu_bar; @@ -28,6 +29,10 @@ impl eframe::App for TextEditor { tab_bar(self, ctx); } + if !self.hide_bottom_bar { + bottom_bar(self, ctx); + } + central_panel(self, ctx); if self.show_about { diff --git a/src/app/state/default.rs b/src/app/state/default.rs index 3049b2a..3d246b3 100644 --- a/src/app/state/default.rs +++ b/src/app/state/default.rs @@ -14,6 +14,7 @@ impl Default for TextEditor { show_shortcuts: false, show_find: false, show_preferences: false, + show_markdown: false, pending_unsaved_action: None, force_quit_confirmed: false, clean_quit_requested: false, @@ -21,6 +22,7 @@ impl Default for TextEditor { word_wrap: true, auto_hide_toolbar: false, hide_tab_bar: true, + hide_bottom_bar: false, syntax_highlighting: false, theme: Theme::default(), line_side: false, @@ -45,6 +47,7 @@ impl Default for TextEditor { previous_content: String::new(), previous_cursor_char_index: None, current_cursor_line: 0, + current_cursor_index: 0, previous_cursor_line: 0, font_settings_changed: false, text_needs_processing: false, diff --git a/src/app/state/editor.rs b/src/app/state/editor.rs index a93bd74..7bc231b 100644 --- a/src/app/state/editor.rs +++ b/src/app/state/editor.rs @@ -39,6 +39,7 @@ pub struct TextEditor { pub(crate) show_shortcuts: bool, pub(crate) show_find: bool, pub(crate) show_preferences: bool, + pub(crate) show_markdown: bool, pub(crate) pending_unsaved_action: Option, pub(crate) force_quit_confirmed: bool, pub(crate) clean_quit_requested: bool, @@ -46,6 +47,7 @@ pub struct TextEditor { pub(crate) word_wrap: bool, pub(crate) auto_hide_toolbar: bool, pub(crate) hide_tab_bar: bool, + pub(crate) hide_bottom_bar: bool, pub(crate) syntax_highlighting: bool, pub(crate) theme: Theme, pub(crate) line_side: bool, @@ -69,6 +71,7 @@ pub struct TextEditor { pub(crate) previous_content: String, pub(crate) previous_cursor_char_index: Option, pub(crate) current_cursor_line: usize, + pub(crate) current_cursor_index: usize, pub(crate) previous_cursor_line: usize, pub(crate) font_settings_changed: bool, pub(crate) text_needs_processing: bool, diff --git a/src/app/state/find.rs b/src/app/state/find.rs index 0e30610..0414cc2 100644 --- a/src/app/state/find.rs +++ b/src/app/state/find.rs @@ -1,4 +1,5 @@ use super::editor::TextEditor; +use crate::util::safe_slice_to_pos; use eframe::egui; impl TextEditor { @@ -114,10 +115,15 @@ impl TextEditor { 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 start_char = safe_slice_to_pos(content, start_byte).chars().count(); + let end_char = safe_slice_to_pos(content, end_byte).chars().count(); - let text_edit_id = egui::Id::new("main_text_editor"); + let id_source = active_tab + .file_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "untitled".to_string()); + let text_edit_id = egui::Id::new("main_text_editor").with(&id_source); if let Some(mut state) = egui::TextEdit::load_state(ctx, text_edit_id) { let selection_range = egui::text::CCursorRange::two( egui::text::CCursor::new(start_char), @@ -152,12 +158,16 @@ impl TextEditor { 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 replacement_end_char = safe_slice_to_pos(&active_tab.content, replacement_end) + .chars() + .count(); - let text_edit_id = egui::Id::new("main_text_editor"); + let id_source = active_tab + .file_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "untitled".to_string()); + let text_edit_id = egui::Id::new("main_text_editor").with(&id_source); if let Some(mut state) = egui::TextEdit::load_state(ctx, text_edit_id) { state .cursor @@ -206,14 +216,22 @@ impl TextEditor { 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); + if let Some(active_tab) = self.get_active_tab() { + let id_source = active_tab + .file_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "untitled".to_string()); + let text_edit_id = egui::Id::new("main_text_editor").with(&id_source); + + if let Some(mut state) = egui::TextEdit::load_state(ctx, text_edit_id) { + state + .cursor + .set_char_range(Some(egui::text::CCursorRange::one( + egui::text::CCursor::new(0), + ))); + egui::TextEdit::store_state(ctx, text_edit_id, state); + } } } } diff --git a/src/app/state/processing.rs b/src/app/state/processing.rs index 78e3e24..7053f29 100644 --- a/src/app/state/processing.rs +++ b/src/app/state/processing.rs @@ -1,16 +1,8 @@ use super::editor::{TextEditor, TextProcessingResult}; +use crate::util::safe_slice_to_pos; use eframe::egui; impl TextEditor { - pub(crate) fn safe_slice_to_pos(content: &str, pos: usize) -> &str { - let pos = pos.min(content.len()); - let mut boundary_pos = pos; - while boundary_pos > 0 && !content.is_char_boundary(boundary_pos) { - boundary_pos -= 1; - } - &content[..boundary_pos] - } - pub fn process_text_for_rendering(&mut self, content: &str, ui: &egui::Ui) { let line_count = content.bytes().filter(|&b| b == b'\n').count() + 1; @@ -50,13 +42,12 @@ impl TextEditor { let font_id = self.get_font_id(); let longest_line_pixel_width = if longest_line_length > 0 { let longest_line_text = lines[longest_line_index]; - ui.fonts(|fonts| { + ui.fonts_mut(|fonts| { fonts - .layout( + .layout_no_wrap( longest_line_text.to_string(), font_id, egui::Color32::WHITE, - f32::INFINITY, ) .size() .x @@ -83,15 +74,6 @@ impl TextEditor { new_cursor_pos: usize, ui: &egui::Ui, ) { - let line_change = self.calculate_cursor_line_change( - old_content, - new_content, - old_cursor_pos, - new_cursor_pos, - ); - - self.current_cursor_line = (self.current_cursor_line as isize + line_change) as usize; - if old_content.len() == new_content.len() { self.handle_character_replacement( old_content, @@ -128,12 +110,12 @@ impl TextEditor { old_cursor_pos: usize, new_cursor_pos: usize, ) -> isize { - let old_newlines = Self::safe_slice_to_pos(old_content, old_cursor_pos) + let old_newlines = safe_slice_to_pos(old_content, old_cursor_pos) .bytes() .filter(|&b| b == b'\n') .count(); - let new_newlines = Self::safe_slice_to_pos(new_content, new_cursor_pos) + let new_newlines = safe_slice_to_pos(new_content, new_cursor_pos) .bytes() .filter(|&b| b == b'\n') .count(); @@ -198,11 +180,11 @@ impl TextEditor { let mut current_result = self.get_text_processing_result(); current_result.line_count += newlines_added; - let addition_start_line = Self::safe_slice_to_pos(old_content, added_start) + let addition_start_line = 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) + let addition_end_line = safe_slice_to_pos(old_content, added_end) .bytes() .filter(|&b| b == b'\n') .count(); @@ -268,11 +250,11 @@ impl TextEditor { let mut current_result = self.get_text_processing_result(); current_result.line_count = current_result.line_count.saturating_sub(newlines_removed); - let removal_start_line = Self::safe_slice_to_pos(old_content, removed_start) + let removal_start_line = safe_slice_to_pos(old_content, removed_start) .bytes() .filter(|&b| b == b'\n') .count(); - let removal_end_line = Self::safe_slice_to_pos(old_content, removed_end) + let removal_end_line = safe_slice_to_pos(old_content, removed_end) .bytes() .filter(|&b| b == b'\n') .count(); @@ -331,7 +313,7 @@ impl TextEditor { { content[line_start_boundary..line_end_boundary].to_string() } else { - Self::safe_slice_to_pos(content, line_end_boundary)[line_start_boundary..].to_string() + safe_slice_to_pos(content, line_end_boundary)[line_start_boundary..].to_string() } } @@ -346,13 +328,12 @@ impl TextEditor { if line_length > current_result.longest_line_length { let font_id = self.get_font_id(); - let pixel_width = ui.fonts(|fonts| { + let pixel_width = ui.fonts_mut(|fonts| { fonts - .layout( + .layout_no_wrap( line_content.to_string(), font_id, egui::Color32::WHITE, - f32::INFINITY, ) .size() .x diff --git a/src/app/state/ui.rs b/src/app/state/ui.rs index ce20a6f..d3c3b75 100644 --- a/src/app/state/ui.rs +++ b/src/app/state/ui.rs @@ -68,7 +68,7 @@ impl TextEditor { let font_id = self.get_font_id(); let line_count_digits = line_count.to_string().len(); let sample_text = "9".repeat(line_count_digits); - let base_line_number_width = ui.fonts(|fonts| { + let base_line_number_width = ui.fonts_mut(|fonts| { fonts .layout(sample_text, font_id, egui::Color32::WHITE, f32::INFINITY) .size() @@ -76,7 +76,7 @@ impl TextEditor { }); let line_number_width = if self.line_side { - base_line_number_width + 25.0 // Scrollbar width + base_line_number_width + crate::ui::constants::SCROLLBAR_WIDTH } else { base_line_number_width }; @@ -95,10 +95,123 @@ impl TextEditor { return self.calculate_editor_dimensions(ui).text_width; } - let longest_line_width = - processing_result.longest_line_pixel_width + (self.font_size * 3.0); + let longest_line_width = processing_result.longest_line_pixel_width; + let font_id = self.get_font_id(); + let char_width = ui.fonts_mut(|fonts| { + fonts + .layout_no_wrap("M".to_string(), font_id, egui::Color32::WHITE) + .size() + .x + }); + let extra_space = char_width * 2.0; let dimensions = self.calculate_editor_dimensions(ui); - longest_line_width.max(dimensions.text_width) + (longest_line_width + extra_space).max(dimensions.text_width) + } + + pub fn get_cursor_position(&self) -> (usize, usize) { + if let Some(active_tab) = self.get_active_tab() { + let content = &active_tab.content; + let safe_pos = self.current_cursor_index.min(content.len()); + + // Calculate column (chars since last newline) + let mut column = 0; + for c in content[..safe_pos].chars().rev() { + if c == '\n' { + break; + } + column += 1; + } + + (column + 1, self.current_cursor_line) + } else { + (0, 0) + } + } + + pub fn handle_page_movement(&mut self, ctx: &egui::Context, direction_down: bool) { + let Some(active_tab) = self.get_active_tab() else { + return; + }; + + let id_source = active_tab + .file_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "untitled".to_string()); + let text_edit_id = egui::Id::new("main_text_editor").with(&id_source); + + let Some(mut state) = egui::TextEdit::load_state(ctx, text_edit_id) else { + return; + }; + + let Some(cursor_range) = state.cursor.char_range() else { + return; + }; + let current_pos = cursor_range.primary.index; + + let content = &active_tab.content; + + let available_height = ctx.available_rect().height(); + let row_height = self.font_size * 1.5; + let visible_rows = (available_height / row_height).floor() as usize; + let rows_to_move = visible_rows.max(1); + + let new_pos = if direction_down { + move_cursor_down_lines(content, current_pos, rows_to_move) + } else { + move_cursor_up_lines(content, current_pos, rows_to_move) + }; + + let new_cursor = egui::text::CCursor::new(new_pos); + state + .cursor + .set_char_range(Some(egui::text::CCursorRange::one(new_cursor))); + egui::TextEdit::store_state(ctx, text_edit_id, state); } } + +fn move_cursor_down_lines(content: &str, current_pos: usize, lines: usize) -> usize { + let safe_pos = current_pos.min(content.len()); + + let mut pos = safe_pos; + let mut lines_moved = 0; + + for (idx, ch) in content[safe_pos..].char_indices() { + if ch == '\n' { + lines_moved += 1; + if lines_moved >= lines { + pos = safe_pos + idx + 1; + break; + } + } + } + + if lines_moved < lines && pos == safe_pos { + pos = content.len(); + } + + pos.min(content.len()) +} + +fn move_cursor_up_lines(content: &str, current_pos: usize, lines: usize) -> usize { + let safe_pos = current_pos.min(content.len()); + + let mut pos = safe_pos; + let mut lines_moved = 0; + + for ch in content[..safe_pos].chars().rev() { + if pos > 0 { + pos -= ch.len_utf8(); + } + + if ch == '\n' { + lines_moved += 1; + if lines_moved >= lines { + break; + } + } + } + + pos +} diff --git a/src/main.rs b/src/main.rs index de06732..9ed7989 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,8 @@ use std::path::PathBuf; mod app; mod io; mod ui; -use app::{config::Config, TextEditor}; +mod util; +use app::{TextEditor, config::Config}; fn main() -> eframe::Result { let args: Vec = env::args().collect(); diff --git a/src/ui.rs b/src/ui.rs index dd29176..b42d426 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,4 +1,5 @@ pub(crate) mod about_window; +pub(crate) mod bottom_bar; pub(crate) mod central_panel; pub(crate) mod constants; pub(crate) mod find_window; diff --git a/src/ui/bottom_bar.rs b/src/ui/bottom_bar.rs new file mode 100644 index 0000000..02ee8a9 --- /dev/null +++ b/src/ui/bottom_bar.rs @@ -0,0 +1,43 @@ +use crate::app::TextEditor; +use crate::ui::central_panel::languages::get_language_from_extension; +use eframe::egui::{self, Frame}; + +pub(crate) fn bottom_bar(app: &mut TextEditor, ctx: &egui::Context) { + let line_count = app.get_text_processing_result().line_count; + let char_count = app + .get_active_tab() + .map(|tab| tab.content.chars().count()) + .unwrap_or(0); + let cursor_position = app.get_cursor_position(); + let cursor_column = cursor_position.0; + let cursor_row = cursor_position.1; + + let active_tab = app.get_active_tab(); + let file_path = active_tab.and_then(|tab| tab.file_path.as_deref()); + let language = get_language_from_extension(file_path); + + if !app.hide_bottom_bar { + let frame = Frame::NONE.fill(ctx.style().visuals.panel_fill); + egui::TopBottomPanel::bottom("bottom_bar") + .frame(frame) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.add_space(8.0); + ui.label(format!("Ln {}, Col {}", cursor_row, cursor_column)); + ui.separator(); + ui.label(format!("{} chars, {} lines", char_count, line_count)); + }); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(8.0); + ui.label(format!("{}", language.to_uppercase())); + ui.separator(); + ui.label("UTF-8"); + ui.separator(); + ui.label(format!("{}pt", app.font_size as u32)); + }); + }); + }); + } +} diff --git a/src/ui/central_panel.rs b/src/ui/central_panel.rs index b6d3609..54d9ea6 100644 --- a/src/ui/central_panel.rs +++ b/src/ui/central_panel.rs @@ -1,7 +1,8 @@ mod editor; mod find_highlight; -mod languages; +pub mod languages; mod line_numbers; +mod markdown; use crate::app::TextEditor; use crate::ui::constants::*; @@ -9,7 +10,16 @@ use eframe::egui; use egui::UiKind; use self::editor::editor_view_ui; +use self::languages::get_language_from_extension; use self::line_numbers::{calculate_visual_line_mapping, render_line_numbers}; +use self::markdown::markdown_view_ui; + +fn is_markdown_tab(app: &TextEditor) -> bool { + app.get_active_tab() + .and_then(|tab| tab.file_path.as_deref()) + .map(|path| get_language_from_extension(Some(path)) == "md") + .unwrap_or(false) +} pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { let show_line_numbers = app.show_line_numbers; @@ -17,6 +27,8 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { let line_side = app.line_side; let font_size = app.font_size; let font_id = app.get_font_id(); + let show_markdown = app.show_markdown; + let is_markdown_file = is_markdown_tab(app); let _output = egui::CentralPanel::default() .frame(egui::Frame::NONE) @@ -26,6 +38,66 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { ui.painter().rect_filled(panel_rect, 0.0, bg_color); let editor_height = panel_rect.height(); + // Handle markdown split view + if show_markdown && is_markdown_file { + let half_width = panel_rect.width() / 2.0; + + ui.push_id("markdown_split_container", |ui| { + ui.allocate_ui_with_layout( + egui::vec2(panel_rect.width(), editor_height), + egui::Layout::left_to_right(egui::Align::TOP), + |ui| { + // Left side: Editor + ui.allocate_ui_with_layout( + egui::vec2(half_width, editor_height), + egui::Layout::top_down(egui::Align::LEFT), + |ui| { + egui::ScrollArea::vertical() + .id_salt("editor_scroll_area") + .auto_shrink([false; 2]) + .show(ui, |ui| { + editor_view_ui(ui, app); + }); + }, + ); + + // Separator + let separator_x = ui.cursor().left(); + let mut y_range = ui.available_rect_before_wrap().y_range(); + y_range.max += 2.0 * font_size; + ui.painter() + .vline(separator_x, y_range, ui.visuals().window_stroke); + ui.add_space(SMALL); + + // Right side: Markdown view + ui.allocate_ui_with_layout( + egui::vec2(half_width, editor_height), + egui::Layout::top_down(egui::Align::LEFT), + |ui| { + egui::ScrollArea::vertical() + .id_salt("markdown_scroll_area") + .auto_shrink([false; 2]) + .show(ui, |ui| { + egui::Frame::new() + .inner_margin(egui::Margin { + left: 0, + right: SCROLLBAR_WIDTH as i8, + top: 0, + bottom: 0, + }) + .show(ui, |ui| { + markdown_view_ui(ui, app); + }); + }); + }, + ); + }, + ); + }); + + return; + } + if !show_line_numbers || app.get_active_tab().is_none() { let _scroll_response = egui::ScrollArea::vertical() @@ -35,46 +107,19 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { let context_response = ui.allocate_response(full_rect.size(), egui::Sense::click()); - ui.scope_builder(egui::UiBuilder::new().max_rect(full_rect), |ui| { - editor_view_ui(ui, app); - }); + let editor_response = ui + .scope_builder(egui::UiBuilder::new().max_rect(full_rect), |ui| { + editor_view_ui(ui, app) + }) + .inner; - handle_empty(ui, app, &context_response); + handle_empty(ui, app, &context_response, &editor_response); }); return; } let line_count = app.get_text_processing_result().line_count; - let editor_dimensions = app.calculate_editor_dimensions(ui); - let line_number_width = editor_dimensions.line_number_width; - let editor_width = editor_dimensions.text_width - line_number_width; - - let visual_line_mapping = if word_wrap { - app.get_active_tab() - .map(|active_tab| { - let actual_editor_width = ui.available_width() - line_number_width; - calculate_visual_line_mapping( - ui, - &active_tab.content, - actual_editor_width, - font_id, - ) - }) - .unwrap_or_else(Vec::new) - } else { - Vec::new() - }; - let line_numbers_widget = |ui: &mut egui::Ui| { - render_line_numbers( - ui, - line_count, - &visual_line_mapping, - line_number_width, - word_wrap, - line_side, - font_size, - ); - }; + let line_number_width = app.calculate_editor_dimensions(ui).line_number_width; let separator_widget = |ui: &mut egui::Ui| { let separator_x = ui.cursor().left(); @@ -88,13 +133,43 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { egui::ScrollArea::vertical() .auto_shrink([false; 2]) .show(ui, |ui| { + let available_width = ui.available_width(); + let actual_editor_width = (available_width - line_number_width).max(0.0); + + let visual_line_mapping = if word_wrap { + app.get_active_tab() + .map(|active_tab| { + calculate_visual_line_mapping( + ui, + &active_tab.content, + actual_editor_width - (if line_side { 8.0 } else { 20.0 }), + font_id, + ) + }) + .unwrap_or_default() + } else { + Vec::new() + }; + + let line_numbers_widget = |ui: &mut egui::Ui| { + render_line_numbers( + ui, + line_count, + &visual_line_mapping, + line_number_width, + word_wrap, + line_side, + font_size, + ); + }; + if line_side { ui.allocate_ui_with_layout( - egui::vec2(editor_dimensions.text_width, editor_height), + egui::vec2(available_width, editor_height), egui::Layout::left_to_right(egui::Align::TOP), |ui| { ui.allocate_ui_with_layout( - egui::vec2(editor_width, editor_height), + egui::vec2(actual_editor_width, editor_height), egui::Layout::left_to_right(egui::Align::TOP), |ui| { let full_rect: egui::Rect = ui.available_rect_before_wrap(); @@ -103,14 +178,14 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { egui::Sense::click(), ); - ui.scope_builder( - egui::UiBuilder::new().max_rect(full_rect), - |ui| { - editor_view_ui(ui, app); - }, - ); + let editor_response = ui + .scope_builder( + egui::UiBuilder::new().max_rect(full_rect), + |ui| editor_view_ui(ui, app), + ) + .inner; - handle_empty(ui, app, &context_response); + handle_empty(ui, app, &context_response, &editor_response); }, ); separator_widget(ui); @@ -119,7 +194,7 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { ); } else { ui.allocate_ui_with_layout( - egui::vec2(editor_dimensions.text_width, editor_height), + egui::vec2(available_width, editor_height), egui::Layout::left_to_right(egui::Align::TOP), |ui| { line_numbers_widget(ui); @@ -129,14 +204,14 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { let context_response = ui.allocate_response(editor_area.size(), egui::Sense::click()); - ui.scope_builder( - egui::UiBuilder::new().max_rect(editor_area), - |ui| { - editor_view_ui(ui, app); - }, - ); + let editor_response = ui + .scope_builder( + egui::UiBuilder::new().max_rect(editor_area), + |ui| editor_view_ui(ui, app), + ) + .inner; - handle_empty(ui, app, &context_response); + handle_empty(ui, app, &context_response, &editor_response); }, ); } @@ -144,27 +219,33 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) { }); } -fn handle_empty(_ui: &mut egui::Ui, app: &mut TextEditor, context_response: &egui::Response) { +fn handle_empty( + ui: &mut egui::Ui, + app: &mut TextEditor, + context_response: &egui::Response, + editor_response: &egui::Response, +) { if context_response.clicked() { - let text_edit_id = egui::Id::new("main_text_editor"); - if let Some(mut state) = egui::TextEdit::load_state(_ui.ctx(), text_edit_id) { + let text_edit_id = editor_response.id; + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { if let Some(active_tab) = app.get_active_tab() { - let text_len = active_tab.content.len(); + let text_len = active_tab.content.chars().count(); let cursor_pos = egui::text::CCursor::new(text_len); state .cursor .set_char_range(Some(egui::text::CCursorRange::one(cursor_pos))); - egui::TextEdit::store_state(_ui.ctx(), text_edit_id, state); + egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); - _ui.ctx().memory_mut(|mem| { + ui.ctx().memory_mut(|mem| { mem.request_focus(text_edit_id); }); } } } - context_response.context_menu(|ui| { - let text_len = app.get_active_tab().unwrap().content.len(); + // Use the editor response for context menu so it captures right-clicks in the text area + editor_response.clone().context_menu(|ui| { + let text_len = app.get_active_tab().unwrap().content.chars().count(); let reset_zoom_key = egui::Id::new("editor_reset_zoom"); if ui.button("Cut").clicked() { @@ -195,7 +276,7 @@ fn handle_empty(_ui: &mut egui::Ui, app: &mut TextEditor, context_response: &egu ui.close_kind(UiKind::Menu); } if ui.button("Select All").clicked() { - let text_edit_id = egui::Id::new("main_text_editor"); + let text_edit_id = editor_response.id; if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { let select_all_range = egui::text::CCursorRange::two( egui::text::CCursor::new(0), diff --git a/src/ui/central_panel/editor.rs b/src/ui/central_panel/editor.rs index ab54df5..716dcab 100644 --- a/src/ui/central_panel/editor.rs +++ b/src/ui/central_panel/editor.rs @@ -5,16 +5,16 @@ 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 { - let _current_match_position = app.get_current_match_position(); let show_find = app.show_find; - let _prev_show_find = app.prev_show_find; let show_preferences = app.show_preferences; let show_about = app.show_about; let show_shortcuts = app.show_shortcuts; + let word_wrap = app.word_wrap; let font_size = app.font_size; let font_id = app.get_font_id(); let syntax_highlighting_enabled = app.syntax_highlighting; + let previous_cursor_position = app.previous_cursor_position; let bg_color = ui.visuals().extreme_bg_color; let editor_rect = ui.available_rect_before_wrap(); @@ -55,30 +55,34 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R return ui.label("No file open, how did you get here?"); }; - if let Some((content, matches, current_match_index)) = &find_data { - let temp_galley = ui.fonts(|fonts| { - fonts.layout( - content.to_owned(), - font_id.to_owned(), - ui.visuals().text_color(), - desired_width, - ) - }); + let draw_highlights = |ui: &mut egui::Ui| { + if let Some((content, matches, current_match_index)) = &find_data { + let temp_galley = ui.fonts_mut(|fonts| { + fonts.layout( + content.to_owned(), + font_id.to_owned(), + ui.visuals().text_color(), + desired_width - 8.0, + ) + }); - let text_area_left = editor_rect.left() + 4.0; // Text Editor default margins - let text_area_top = editor_rect.top() + 2.0; + // Use the current cursor position which handles scroll offsets correctly + let cursor_pos = ui.cursor().min; + let text_area_left = cursor_pos.x + 4.0; // Text Editor default margins + let text_area_top = cursor_pos.y + 2.0; - find_highlight::draw_find_highlights( - ui, - content, - matches, - *current_match_index, - &temp_galley, - text_area_left, - text_area_top, - font_size, - ); - } + find_highlight::draw_find_highlights( + ui, + content, + matches, + *current_match_index, + &temp_galley, + text_area_left, + text_area_top, + font_size, + ); + } + }; 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| { @@ -102,9 +106,21 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R } layout_job.wrap.max_width = wrap_width; - ui.fonts(|f| f.layout_job(layout_job)) + ui.fonts_mut(|f| f.layout_job(layout_job)) }; + let id_source = active_tab + .file_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "untitled".to_string()); + let text_edit_id = egui::Id::new("main_text_editor").with(&id_source); + + let allow_interaction = ui.is_enabled() + && !ui.input(|i| { + i.pointer.button_down(egui::PointerButton::Secondary) + || i.pointer.button_down(egui::PointerButton::Middle) + }); let text_edit = egui::TextEdit::multiline(&mut active_tab.content) .frame(false) .code_editor() @@ -113,9 +129,43 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R .lock_focus(!show_find) .cursor_at_end(false) .layouter(&mut layouter) - .id(egui::Id::new("main_text_editor")); + .interactive(allow_interaction) + .id(text_edit_id); + + let ensure_cursor_visible = |ui: &mut egui::Ui, + output: &egui::text_edit::TextEditOutput, + font_id: &egui::FontId| { + let current_cursor_pos = output + .state + .cursor + .char_range() + .map(|range| range.primary.index); + + if let Some(cursor_pos) = current_cursor_pos { + let cursor_moved = Some(cursor_pos) != previous_cursor_position; + let text_changed = output.response.changed(); + + if cursor_moved || text_changed { + let cursor_rect = output + .galley + .pos_from_cursor(egui::text::CCursor::new(cursor_pos)); + + let global_cursor_rect = cursor_rect.translate(output.response.rect.min.to_vec2()); + + let line_height = ui.fonts_mut(|fonts| fonts.row_height(font_id)); + let margin = egui::vec2(40.0, line_height * 2.0); + let target_rect = global_cursor_rect.expand2(margin); + + let visible_area = ui.clip_rect(); + if !visible_area.contains_rect(target_rect) { + ui.scroll_to_rect(target_rect, Some(egui::Align::Center)); + } + } + } + }; let output = if word_wrap { + draw_highlights(ui); text_edit.show(ui) } else { egui::ScrollArea::horizontal() @@ -124,13 +174,20 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R ui.allocate_ui_with_layout( egui::Vec2::new(estimated_width, ui.available_height()), egui::Layout::left_to_right(egui::Align::TOP), - |ui| text_edit.show(ui), + |ui| { + draw_highlights(ui); + let output = text_edit.show(ui); + ensure_cursor_visible(ui, &output, &font_id); + output + }, ) }) .inner .inner }; + ensure_cursor_visible(ui, &output, &font_id); + let content_changed = output.response.changed(); let content_for_processing = if content_changed { active_tab.update_modified_state(); @@ -139,10 +196,8 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R None }; - if content_changed { - if let Err(e) = app.save_state_cache() { - eprintln!("Failed to save state cache: {e}"); - } + if content_changed && 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() { @@ -189,39 +244,18 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R } if let Some(cursor_pos) = current_cursor_pos { - let cursor_moved = Some(cursor_pos) != app.previous_cursor_position; - let text_changed = output.response.changed(); - - if cursor_moved || text_changed { - if let Some(active_tab) = app.get_active_tab() { - let content = &active_tab.content; - let cursor_line = content - .char_indices() - .take_while(|(byte_pos, _)| *byte_pos < cursor_pos) - .filter(|(_, ch)| *ch == '\n') - .count(); - - let font_id = ui - .style() - .text_styles - .get(&egui::TextStyle::Monospace) - .unwrap_or(&egui::FontId::monospace(font_size)) - .to_owned(); - let line_height = ui.fonts(|fonts| fonts.row_height(&font_id)); - - let y_pos = output.response.rect.top() + (cursor_line as f32 * line_height); - let cursor_rect = egui::Rect::from_min_size( - egui::pos2(output.response.rect.left(), y_pos), - egui::vec2(2.0, line_height), - ); - - let visible_area = ui.clip_rect(); - if !visible_area.intersects(cursor_rect) { - ui.scroll_to_rect(cursor_rect, Some(egui::Align::Center)); - } - } - } app.previous_cursor_position = Some(cursor_pos); + app.current_cursor_index = cursor_pos; + + // Calculate line and column + if let Some(active_tab) = app.get_active_tab() { + let content = &active_tab.content; + let safe_pos = cursor_pos.min(content.len()); + + // Count newlines before cursor for line number + let line_number = content[..safe_pos].chars().filter(|&c| c == '\n').count() + 1; + app.current_cursor_line = line_number; + } } if !output.response.has_focus() diff --git a/src/ui/central_panel/find_highlight.rs b/src/ui/central_panel/find_highlight.rs index 38b813b..c0b861e 100644 --- a/src/ui/central_panel/find_highlight.rs +++ b/src/ui/central_panel/find_highlight.rs @@ -1,15 +1,6 @@ +use crate::util::safe_slice_to_pos; use eframe::egui; -/// Safely get a string slice up to a byte position, ensuring UTF-8 boundaries -fn safe_slice_to_pos(content: &str, pos: usize) -> &str { - let pos = pos.min(content.len()); - let mut boundary_pos = pos; - while boundary_pos > 0 && !content.is_char_boundary(boundary_pos) { - boundary_pos -= 1; - } - &content[..boundary_pos] -} - pub(super) fn draw_find_highlights( ui: &mut egui::Ui, content: &str, @@ -18,26 +9,18 @@ pub(super) fn draw_find_highlights( galley: &std::sync::Arc, text_area_left: f32, text_area_top: f32, - font_size: f32, + _font_size: f32, ) { - let font_id = ui - .style() - .text_styles - .get(&egui::TextStyle::Monospace) - .unwrap_or(&egui::FontId::monospace(font_size)) - .to_owned(); - - for (match_index, &(start_pos, end_pos)) in matches.iter().enumerate() { + for (match_index, &(start_byte, end_byte)) in matches.iter().enumerate() { let is_current_match = current_match_index == Some(match_index); draw_single_highlight( ui, content, - start_pos, - end_pos, + start_byte, + end_byte, + galley, text_area_left, text_area_top, - galley, - &font_id, is_current_match, ); } @@ -46,70 +29,15 @@ pub(super) fn draw_find_highlights( fn draw_single_highlight( ui: &mut egui::Ui, content: &str, - start_pos: usize, - end_pos: usize, + start_byte: usize, + end_byte: usize, + galley: &std::sync::Arc, text_area_left: f32, text_area_top: f32, - galley: &std::sync::Arc, - 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(); - - if start_line >= galley.rows.len() { - return; - } - - let line_start_byte_pos = text_up_to_start.rfind('\n').map(|pos| pos + 1).unwrap_or(0); - let line_start_char_pos = safe_slice_to_pos(content, line_start_byte_pos) - .chars() - .count(); - let start_char_pos = safe_slice_to_pos(content, start_pos).chars().count(); - let start_col = start_char_pos - line_start_char_pos; - - let lines: Vec<&str> = content.lines().collect(); - if start_line >= lines.len() { - return; - } - - let line_text = lines[start_line]; - let text_before_match: String = line_text.chars().take(start_col).collect(); - - let text_before_width = ui.fonts(|fonts| { - fonts - .layout( - text_before_match, - font_id.to_owned(), - egui::Color32::WHITE, - f32::INFINITY, - ) - .size() - .x - }); - - let galley_row = &galley.rows[start_line]; - let start_y = text_area_top + galley_row.min_y(); - let line_height = galley_row.height(); - let start_x = text_area_left + text_before_width; - - let match_text = &content[start_pos..end_pos.min(content.len())]; - let match_width = ui.fonts(|fonts| { - fonts - .layout( - match_text.to_string(), - font_id.to_owned(), - ui.visuals().text_color(), - f32::INFINITY, - ) - .size() - .x - }); - - let highlight_rect = egui::Rect::from_min_size( - egui::pos2(start_x, start_y), - egui::vec2(match_width, line_height), - ); + let start_char = safe_slice_to_pos(content, start_byte).chars().count(); + let end_char = safe_slice_to_pos(content, end_byte).chars().count(); let highlight_color = if is_current_match { ui.visuals().selection.bg_fill @@ -118,5 +46,36 @@ fn draw_single_highlight( }; let painter = ui.painter(); - painter.rect_filled(highlight_rect, 0.0, highlight_color); + + let mut current_char_idx = 0; + + for row in &galley.rows { + let row_start_char = current_char_idx; + let row_end_char = row_start_char + row.char_count_excluding_newline(); + + current_char_idx += row.char_count_including_newline(); + + if row_end_char <= start_char || row_start_char >= end_char { + continue; + } + + let highlight_start_char_in_row = start_char.max(row_start_char) - row_start_char; + let highlight_end_char_in_row = end_char.min(row_end_char) - row_start_char; + + let start_x = row.x_offset(highlight_start_char_in_row); + let end_x = row.x_offset(highlight_end_char_in_row); + + let rect = egui::Rect::from_min_max( + egui::pos2( + text_area_left + row.rect().min.x + start_x, + text_area_top + row.rect().min.y, + ), + egui::pos2( + text_area_left + row.rect().min.x + end_x, + text_area_top + row.rect().max.y, + ), + ); + + painter.rect_filled(rect, 0.0, highlight_color); + } } diff --git a/src/ui/central_panel/line_numbers.rs b/src/ui/central_panel/line_numbers.rs index 2ecb08f..0f1ab05 100644 --- a/src/ui/central_panel/line_numbers.rs +++ b/src/ui/central_panel/line_numbers.rs @@ -2,8 +2,10 @@ use eframe::egui; fn format_line_number(line_number: usize, line_side: bool, line_count_width: usize) -> String { if line_side { - format!("{:width$}", line_number, width = line_count_width) } } @@ -22,12 +24,12 @@ pub(super) fn calculate_visual_line_mapping( continue; } - let galley = ui.fonts(|fonts| { + let galley = ui.fonts_mut(|fonts| { fonts.layout( line.to_string(), font_id.to_owned(), egui::Color32::WHITE, - available_width - font_id.size, + available_width, ) }); @@ -60,7 +62,7 @@ pub(super) fn render_line_numbers( ui.disable(); ui.set_width(line_number_width); ui.spacing_mut().item_spacing.y = 0.0; - ui.add_space(2.0); // Text Editor default top margin + ui.add_space(1.0); // Text Editor default top margin let text_color = ui.visuals().weak_text_color(); let bg_color = ui.visuals().extreme_bg_color; @@ -85,7 +87,7 @@ pub(super) fn render_line_numbers( .map(|i| format_line_number(i, line_side, line_count_width)) .collect::>() }; - + for text in line_texts { ui.label( egui::RichText::new(text) diff --git a/src/ui/central_panel/markdown.rs b/src/ui/central_panel/markdown.rs new file mode 100644 index 0000000..0886f43 --- /dev/null +++ b/src/ui/central_panel/markdown.rs @@ -0,0 +1,14 @@ +use crate::app::TextEditor; +use eframe::egui; +use egui_commonmark::{CommonMarkViewer, CommonMarkCache}; + +pub(super) fn markdown_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) { + let Some(active_tab) = app.get_active_tab() else { + ui.label("No file open"); + return; + }; + + // Properly render Markdown content using CommonMarkViewer + let mut cache = CommonMarkCache::default(); + CommonMarkViewer::new().show(ui, &mut cache, &active_tab.content); +} \ No newline at end of file diff --git a/src/ui/constants.rs b/src/ui/constants.rs index e980900..16de2d4 100644 --- a/src/ui/constants.rs +++ b/src/ui/constants.rs @@ -24,3 +24,5 @@ pub const DEFAULT_FONT_SIZE_STR: &str = "14"; pub const PREVIEW_AREA_MAX_HEIGHT: f32 = 150.0; pub const INNER_MARGIN: i8 = 8; + +pub const SCROLLBAR_WIDTH: f32 = 25.0; diff --git a/src/ui/find_window.rs b/src/ui/find_window.rs index 446e5cf..e68f704 100644 --- a/src/ui/find_window.rs +++ b/src/ui/find_window.rs @@ -59,7 +59,7 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) { let response = ui.add( egui::TextEdit::singleline(&mut app.find_query) .desired_width(250.0) - .hint_text("Enter search text..."), + .hint_text("Search..."), ); if response.changed() { @@ -84,7 +84,7 @@ pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) { let _replace_response = ui.add( egui::TextEdit::singleline(&mut app.replace_query) .desired_width(250.0) - .hint_text("Enter replacement text..."), + .hint_text("Replace..."), ); }); } diff --git a/src/ui/menu_bar.rs b/src/ui/menu_bar.rs index d37da87..ba875bf 100644 --- a/src/ui/menu_bar.rs +++ b/src/ui/menu_bar.rs @@ -103,71 +103,145 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { ui.close_kind(UiKind::Menu); } if ui.button("Select All").clicked() { - let text_edit_id = egui::Id::new("main_text_editor"); - if let Some(mut state) = - egui::TextEdit::load_state(ui.ctx(), text_edit_id) - { - if let Some(active_tab) = app.get_active_tab() { - let text_len = active_tab.content.len(); - let select_all_range = egui::text::CCursorRange::two( - egui::text::CCursor::new(0), - egui::text::CCursor::new(text_len), - ); - state.cursor.set_char_range(Some(select_all_range)); - egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); + if let Some(active_tab) = app.get_active_tab() { + let id_source = active_tab + .file_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "untitled".to_string()); + let text_edit_id = + egui::Id::new("main_text_editor").with(&id_source); + if let Some(mut state) = + egui::TextEdit::load_state(ui.ctx(), text_edit_id) + { + if let Some(active_tab) = app.get_active_tab() { + let text_len = active_tab.content.chars().count(); + let select_all_range = egui::text::CCursorRange::two( + egui::text::CCursor::new(0), + egui::text::CCursor::new(text_len), + ); + state.cursor.set_char_range(Some(select_all_range)); + egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); + } } } ui.close_kind(UiKind::Menu); } ui.separator(); - if ui.button("Undo").clicked() { - let text_edit_id = egui::Id::new("main_text_editor"); - if let Some(mut state) = - egui::TextEdit::load_state(ui.ctx(), text_edit_id) - { - if let Some(active_tab) = app.get_active_tab_mut() { - let current_state = ( - state.cursor.char_range().unwrap_or_default(), - active_tab.content.to_string(), - ); - let mut undoer = state.undoer(); - if let Some((cursor_range, content)) = - undoer.undo(¤t_state) - { - active_tab.content = content.to_string(); - state.cursor.set_char_range(Some(*cursor_range)); - state.set_undoer(undoer); - egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); - active_tab.update_modified_state(); - if app.show_find && !app.find_query.is_empty() { - app.update_find_matches(); + // Check if undo is available + let can_undo = if let Some(active_tab) = app.get_active_tab() { + let id_source = active_tab + .file_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "untitled".to_string()); + let text_edit_id = egui::Id::new("main_text_editor").with(&id_source); + if let Some(state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { + let current_state = ( + state.cursor.char_range().unwrap_or_default(), + active_tab.content.to_string(), + ); + state.undoer().undo(¤t_state).is_some() + } else { + false + } + } else { + false + }; + + if ui.add_enabled(can_undo, egui::Button::new("Undo")).clicked() { + if let Some(active_tab) = app.get_active_tab_mut() { + let id_source = active_tab + .file_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "untitled".to_string()); + let text_edit_id = + egui::Id::new("main_text_editor").with(&id_source); + if let Some(mut state) = + egui::TextEdit::load_state(ui.ctx(), text_edit_id) + { + if let Some(active_tab) = app.get_active_tab_mut() { + let current_state = ( + state.cursor.char_range().unwrap_or_default(), + active_tab.content.to_string(), + ); + let mut undoer = state.undoer(); + if let Some((cursor_range, content)) = + undoer.undo(¤t_state) + { + active_tab.content = content.to_string(); + state.cursor.set_char_range(Some(*cursor_range)); + state.set_undoer(undoer); + egui::TextEdit::store_state( + ui.ctx(), + text_edit_id, + state, + ); + active_tab.update_modified_state(); + if app.show_find && !app.find_query.is_empty() { + app.update_find_matches(); + } } } } } ui.close_kind(UiKind::Menu); } - if ui.button("Redo").clicked() { - let text_edit_id = egui::Id::new("main_text_editor"); - if let Some(mut state) = - egui::TextEdit::load_state(ui.ctx(), text_edit_id) - { - if let Some(active_tab) = app.get_active_tab_mut() { - let current_state = ( - state.cursor.char_range().unwrap_or_default(), - active_tab.content.to_string(), - ); - let mut undoer = state.undoer(); - if let Some((cursor_range, content)) = - undoer.redo(¤t_state) - { - active_tab.content = content.to_string(); - state.cursor.set_char_range(Some(*cursor_range)); - state.set_undoer(undoer); - egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); - active_tab.update_modified_state(); - if app.show_find && !app.find_query.is_empty() { - app.update_find_matches(); + // Check if redo is available + let can_redo = if let Some(active_tab) = app.get_active_tab() { + let id_source = active_tab + .file_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "untitled".to_string()); + let text_edit_id = egui::Id::new("main_text_editor").with(&id_source); + if let Some(state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { + let current_state = ( + state.cursor.char_range().unwrap_or_default(), + active_tab.content.to_string(), + ); + state.undoer().redo(¤t_state).is_some() + } else { + false + } + } else { + false + }; + + if ui.add_enabled(can_redo, egui::Button::new("Redo")).clicked() { + if let Some(active_tab) = app.get_active_tab_mut() { + let id_source = active_tab + .file_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "untitled".to_string()); + let text_edit_id = + egui::Id::new("main_text_editor").with(&id_source); + if let Some(mut state) = + egui::TextEdit::load_state(ui.ctx(), text_edit_id) + { + if let Some(active_tab) = app.get_active_tab_mut() { + let current_state = ( + state.cursor.char_range().unwrap_or_default(), + active_tab.content.to_string(), + ); + let mut undoer = state.undoer(); + if let Some((cursor_range, content)) = + undoer.redo(¤t_state) + { + active_tab.content = content.to_string(); + state.cursor.set_char_range(Some(*cursor_range)); + state.set_undoer(undoer); + egui::TextEdit::store_state( + ui.ctx(), + text_edit_id, + state, + ); + active_tab.update_modified_state(); + if app.show_find && !app.find_query.is_empty() { + app.update_find_matches(); + } } } } @@ -192,6 +266,13 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { app.save_config(); ui.close_kind(UiKind::Menu); } + if ui + .checkbox(&mut app.show_markdown, "Preview Markdown") + .clicked() + { + app.save_config(); + ui.close_kind(UiKind::Menu); + } if ui.checkbox(&mut app.word_wrap, "Word Wrap").clicked() { app.save_config(); ui.close_kind(UiKind::Menu); @@ -200,6 +281,10 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { app.save_config(); ui.close_kind(UiKind::Menu); } + if ui.checkbox(&mut app.hide_bottom_bar, "Hide Bottom Bar").clicked() { + app.save_config(); + ui.close_kind(UiKind::Menu); + } if ui .checkbox(&mut app.auto_hide_toolbar, "Auto Hide Toolbar") .clicked() @@ -289,10 +374,10 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) { empty_tab.get_display_title() }; - let window_width = ctx.screen_rect().width(); + let window_width = ctx.viewport_rect().width(); let font_id = ui.style().text_styles[&egui::TextStyle::Body].to_owned(); - let text_galley = ui.fonts(|fonts| { + let text_galley = ui.fonts_mut(|fonts| { fonts.layout_job(egui::text::LayoutJob::simple_singleline( tab_title, font_id, diff --git a/src/ui/preferences_window.rs b/src/ui/preferences_window.rs index 9f40ed6..f49b1b1 100644 --- a/src/ui/preferences_window.rs +++ b/src/ui/preferences_window.rs @@ -4,7 +4,7 @@ use eframe::egui; pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { let visuals = &ctx.style().visuals; - let screen_rect = ctx.screen_rect(); + let screen_rect = ctx.viewport_rect(); let window_width = (screen_rect.width() * WINDOW_WIDTH_RATIO).clamp(WINDOW_MIN_WIDTH, WINDOW_MAX_WIDTH); let window_height = diff --git a/src/ui/shortcuts_window.rs b/src/ui/shortcuts_window.rs index 560baf3..e5ae7f2 100644 --- a/src/ui/shortcuts_window.rs +++ b/src/ui/shortcuts_window.rs @@ -40,6 +40,8 @@ fn render_shortcuts_content(ui: &mut egui::Ui) { ); ui.label(egui::RichText::new("Ctrl + K: Toggle Word Wrap").size(UI_TEXT_SIZE)); ui.label(egui::RichText::new("Ctrl + H: Toggle Auto Hide Toolbar").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + B: Toggle Bottom Bar").size(UI_TEXT_SIZE)); + ui.label(egui::RichText::new("Ctrl + M: Toggle Markdown Preview").size(UI_TEXT_SIZE)); ui.label(egui::RichText::new("Ctrl + P: Preferences").size(UI_TEXT_SIZE)); ui.label(egui::RichText::new("Ctrl + =/-: Increase/Decrease Font Size").size(UI_TEXT_SIZE)); ui.label(egui::RichText::new("Ctrl + Shift + =/-: Zoom In/Out").size(UI_TEXT_SIZE)); @@ -58,7 +60,7 @@ fn render_shortcuts_content(ui: &mut egui::Ui) { pub(crate) fn shortcuts_window(app: &mut TextEditor, ctx: &egui::Context) { let visuals = &ctx.style().visuals; - let screen_rect = ctx.screen_rect(); + let screen_rect = ctx.viewport_rect(); let window_width = (screen_rect.width() * WINDOW_WIDTH_RATIO).clamp(WINDOW_MIN_WIDTH, WINDOW_MAX_WIDTH); diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..0348add --- /dev/null +++ b/src/util.rs @@ -0,0 +1,8 @@ +pub(crate) fn safe_slice_to_pos(content: &str, pos: usize) -> &str { + let pos = pos.min(content.len()); + let mut boundary_pos = pos; + while boundary_pos > 0 && !content.is_char_boundary(boundary_pos) { + boundary_pos -= 1; + } + &content[..boundary_pos] +}