Merge pull request 'Update to 0.0.4' (#1) from master into release

Reviewed-on: #1
This commit is contained in:
candle 2025-07-15 15:28:36 +00:00
commit ae6b20d0d4
24 changed files with 836 additions and 459 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.c*
Cargo.lock Cargo.lock
/target /target
perf.* perf.*

View File

@ -1,6 +1,6 @@
[package] [package]
name = "ced" name = "ced"
version = "0.0.3" version = "0.0.4"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

25
LICENSE-MIT Normal file
View File

@ -0,0 +1,25 @@
Copyright (c) Filip Bicki <candle@lampnet.io>
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

75
README.md Normal file
View File

@ -0,0 +1,75 @@
# ced - candle's Editor
There is a disturbing lack of simple GUI text editors available on Linux natively. The world of TUI editors is flourishing, but regular people don't 'yank to system register' when they want to move text from one file to another. In the world of GUI text editors you have a few options, all with their own caveats:\
`gedit` -> Good for the GNOME Desktop, but uses GTK-4.0 so it stands out anywhere else.\
`Kate` -> If you're not on KDE already, it comes with tons of overhead (52 packages on Arch Linux for an application to write text).\
`Emacs` -> Requires a degree in understanding its documentation.
(c)andle's (Ed)itor aims to be a single ~~hopefully small~~ binary for that one purpose.
## Features
* Sane text editing with standard keybindings (`Ctrl+A`, `Ctrl+C`, etc.).
* Opens with a blank slate for quick typing, remember Notepad?
* Separate UI zoom that doesn't affect font size (`Ctrl+Shift` + `+`/`-`).
* Ricers rejoice, your `pywal` colors will be used!
* Weirdly smooth typing experience.
## Build and Install
##### Requirements
`git`, `rust`/`rustup`/`cargo`
##### Arch Linux
`sudo pacman -S git rust`
##### Ubuntu/Debian
`sudo apt install git rust`
#### Install
```bash
git clone https://code.lampnet.io/candle/ced
cd ced && cargo build --release
sudo mv target/release/ced /usr/local/bin/
sudo install -Dm644 ced.desktop /usr/share/applications/ced.desktop
```
`ced` should now appear as 'Text Editor' in your application launcher. You can remove the cloned directory at this point.
## Configuration
`ced` will look for, and create if needed, a configuration file at: `$XDG_CONFIG_HOME/ced/config.toml`.
Here is an example `config.toml`:
```toml
auto_hide_toolbar = false
show_line_numbers = false
word_wrap = false
theme = "System"
line_side = false
font_family = "Monospace"
font_size = 16.0
```
### Options
| Option | Default | Description |
|--------|---------|-------------|
| `auto_hide_toolbar` | `false` | If `true`, the menu bar at the top will be hidden. Move your mouse to the top of the window to reveal it. |
| `show_line_numbers` | `false` | If `true`, line numbers will be displayed on the side specified by `line_side`. |
| `line_side` | `false` | If `false`, line numbers are on the left. If `true`, they are on the right. |
| `word_wrap` | `false` | If `true`, lines will wrap when they reach the edge of the window. |
| `font_family` | `"Proportional"` | The font family used for the editor text. |
| `font_size` | `14.0` | `8.0-32.0` The font size for text editing. |
| `theme` | `"System"` | The color scheme for the application. Options: `"System"` (attempts to use colors from `$XDG_CACHE_HOME/wal/colors` if present, otherwise uses system's light/dark mode preference), `"Light"`, or `"Dark"` (manually specify a theme). |
## Future Plans
In order of importance.
| Feature | Info |
| ------- | ---- |
| **Find/Replace:** | In progress. |
| **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. |
| **Vim Mode:** | It's in-escapable. |
| **CLI Mode:** | 💀 |
| **IDE MODE:** | 🤡 |
I use [Helix](https://helix-editor.com/), btw.

View File

@ -6,6 +6,7 @@ use super::theme::Theme;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config { pub struct Config {
pub auto_hide_toolbar: bool, pub auto_hide_toolbar: bool,
pub auto_hide_tab_bar: bool,
pub show_line_numbers: bool, pub show_line_numbers: bool,
pub word_wrap: bool, pub word_wrap: bool,
pub theme: Theme, pub theme: Theme,
@ -19,6 +20,7 @@ impl Default for Config {
fn default() -> Self { fn default() -> Self {
Self { Self {
auto_hide_toolbar: false, auto_hide_toolbar: false,
auto_hide_tab_bar: false,
show_line_numbers: false, show_line_numbers: false,
word_wrap: true, word_wrap: true,
theme: Theme::default(), theme: Theme::default(),
@ -52,7 +54,7 @@ impl Config {
if !config_path.exists() { if !config_path.exists() {
let default_config = Self::default(); let default_config = Self::default();
if let Err(e) = default_config.save() { if let Err(e) = default_config.save() {
eprintln!("Failed to create default config file: {}", e); eprintln!("Failed to create default config file: {e}");
} }
return default_config; return default_config;
} }

View File

@ -143,7 +143,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, _ctx: &egui::Context) -> bool {
match action { match action {
ShortcutAction::NewFile => { ShortcutAction::NewFile => {
io::new_file(editor); io::new_file(editor);
@ -171,7 +171,9 @@ fn execute_action(action: ShortcutAction, editor: &mut TextEditor, ctx: &egui::C
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 // Show dialog for unsaved changes
editor.pending_unsaved_action = Some(super::state::UnsavedAction::CloseTab(editor.active_tab_index)); editor.pending_unsaved_action = Some(
super::state::UnsavedAction::CloseTab(editor.active_tab_index),
);
} else { } else {
// Close tab directly if no unsaved changes // Close tab directly if no unsaved changes
editor.close_tab(editor.active_tab_index); editor.close_tab(editor.active_tab_index);

View File

@ -1,12 +1,12 @@
use crate::app::shortcuts;
use crate::ui::{
about_window::about_window, central_panel::central_panel, find_window::find_window,
menu_bar::menu_bar, preferences_window::preferences_window, shortcuts_window::shortcuts_window,
tab_bar::tab_bar,
};
use eframe::egui;
use super::editor::TextEditor; use super::editor::TextEditor;
use crate::app::shortcuts;
use crate::ui::about_window::about_window;
use crate::ui::central_panel::central_panel;
use crate::ui::find_window::find_window;
use crate::ui::menu_bar::menu_bar;
use crate::ui::preferences_window::preferences_window;
use crate::ui::shortcuts_window::shortcuts_window;
use crate::ui::tab_bar::tab_bar;
impl eframe::App for TextEditor { impl eframe::App for TextEditor {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
@ -24,77 +24,8 @@ impl eframe::App for TextEditor {
menu_bar(self, ctx); menu_bar(self, ctx);
// if self.tabs.len() > 1 { if !self.auto_hide_tab_bar {
tab_bar(self, ctx); tab_bar(self, ctx);
// }
// Extract data needed for calculations to avoid borrow conflicts
let (content_changed, layout_changed, needs_processing) = if let Some(active_tab) = self.get_active_tab() {
let content_changed = active_tab.last_content_hash != crate::app::tab::compute_content_hash(&active_tab.content, &mut std::hash::DefaultHasher::new());
let layout_changed = self.needs_width_calculation(ctx);
(content_changed, layout_changed, true)
} else {
(false, false, false)
};
if needs_processing {
// Only recalculate width when layout parameters change, not on every keystroke
if layout_changed {
let width = if self.word_wrap {
// For word wrap, width only depends on layout parameters
let total_width = ctx.available_rect().width();
if self.show_line_numbers {
let line_count = if let Some(tab) = self.get_active_tab() {
tab.content.lines().count().max(1)
} else {
1
};
let line_count_digits = line_count.to_string().len();
let estimated_char_width = self.font_size * 0.6;
let base_line_number_width = line_count_digits as f32 * estimated_char_width;
let line_number_width = if self.line_side {
base_line_number_width + 20.0
} else {
base_line_number_width + 8.0
};
(total_width - line_number_width - 10.0).max(100.0)
} else {
total_width
}
} else {
// For non-word wrap, use a generous fixed width to avoid constant recalculation
// This prevents cursor jumping while still allowing horizontal scrolling
let base_width = ctx.available_rect().width();
if self.show_line_numbers {
let estimated_char_width = self.font_size * 0.6;
let line_number_width = if self.line_side { 60.0 } else { 40.0 };
(base_width - line_number_width - 10.0).max(100.0)
} else {
base_width
}
};
self.update_width_calculation_state(ctx, width);
}
// Process text changes using stable cached width
if content_changed {
if let Some(active_tab) = self.get_active_tab() {
let content = active_tab.content.clone();
let word_wrap = self.word_wrap;
let cached_width = self.get_cached_width();
let available_width = cached_width.unwrap_or_else(|| {
// Initialize with a reasonable default if no cache exists
if word_wrap {
ctx.available_rect().width()
} else {
ctx.available_rect().width()
}
});
self.process_text_for_rendering(&content, available_width);
}
}
} }
central_panel(self, ctx); central_panel(self, ctx);
@ -117,6 +48,5 @@ impl eframe::App for TextEditor {
// Update the previous find state for next frame // Update the previous find state for next frame
self.prev_show_find = self.show_find; self.prev_show_find = self.show_find;
} }
} }

View File

@ -19,6 +19,7 @@ impl TextEditor {
show_line_numbers: config.show_line_numbers, show_line_numbers: config.show_line_numbers,
word_wrap: config.word_wrap, word_wrap: config.word_wrap,
auto_hide_toolbar: config.auto_hide_toolbar, auto_hide_toolbar: config.auto_hide_toolbar,
auto_hide_tab_bar: config.auto_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,
@ -29,23 +30,23 @@ impl TextEditor {
tab_bar_rect: None, tab_bar_rect: None,
menu_bar_stable_until: None, menu_bar_stable_until: None,
text_processing_result: std::sync::Arc::new(std::sync::Mutex::new(Default::default())), text_processing_result: std::sync::Arc::new(std::sync::Mutex::new(Default::default())),
processing_thread_handle: None, _processing_thread_handle: None,
find_query: String::new(), find_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,
prev_show_find: false, prev_show_find: false,
// Width calculation cache and state tracking
cached_width: None,
last_word_wrap: config.word_wrap,
last_show_line_numbers: config.show_line_numbers,
last_font_size: config.font_size,
last_line_side: config.line_side,
last_viewport_width: 0.0,
// vim_mode: config.vim_mode, // vim_mode: config.vim_mode,
// Cursor tracking for smart scrolling // Cursor tracking for smart scrolling
previous_cursor_position: None, 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,
} }
} }
@ -72,8 +73,6 @@ impl TextEditor {
editor.apply_font_settings(&cc.egui_ctx); editor.apply_font_settings(&cc.egui_ctx);
editor.start_text_processing_thread();
editor editor
} }
@ -81,6 +80,7 @@ impl TextEditor {
Config { Config {
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,
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,
@ -93,7 +93,7 @@ impl TextEditor {
pub fn save_config(&self) { pub fn save_config(&self) {
let config = self.get_config(); let config = self.get_config();
if let Err(e) = config.save() { if let Err(e) = config.save() {
eprintln!("Failed to save configuration: {}", e); eprintln!("Failed to save configuration: {e}");
} }
} }
} }

View File

@ -19,6 +19,7 @@ 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,
theme: Theme::default(), theme: Theme::default(),
line_side: false, line_side: false,
font_family: "Proportional".to_string(), font_family: "Proportional".to_string(),
@ -29,24 +30,23 @@ impl Default for TextEditor {
tab_bar_rect: None, tab_bar_rect: None,
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 functionality
find_query: String::new(), find_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,
prev_show_find: false, prev_show_find: false,
// Width calculation cache and state tracking
cached_width: None,
last_word_wrap: true,
last_show_line_numbers: false,
last_font_size: 14.0,
last_line_side: false,
last_viewport_width: 0.0,
// vim_mode: false,
// Cursor tracking for smart scrolling // Cursor tracking for smart scrolling
previous_cursor_position: None, 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,
} }
} }
} }

View File

@ -13,18 +13,18 @@ pub enum UnsavedAction {
#[derive(Clone)] #[derive(Clone)]
pub struct TextProcessingResult { pub struct TextProcessingResult {
pub line_count: usize, pub line_count: usize,
pub visual_line_mapping: Vec<Option<usize>>, pub longest_line_index: usize, // Which line is the longest (0-based)
pub max_line_length: f32, pub longest_line_length: usize, // Character count of the longest line
pub _processed_content: String, pub longest_line_pixel_width: f32, // Actual pixel width of the longest line
} }
impl Default for TextProcessingResult { impl Default for TextProcessingResult {
fn default() -> Self { fn default() -> Self {
Self { Self {
line_count: 1, line_count: 1,
visual_line_mapping: vec![Some(1)], longest_line_index: 0,
max_line_length: 0.0, longest_line_length: 0,
_processed_content: String::new(), longest_line_pixel_width: 0.0,
} }
} }
} }
@ -44,6 +44,7 @@ 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) 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,
@ -54,22 +55,20 @@ pub struct TextEditor {
pub(crate) tab_bar_rect: Option<egui::Rect>, pub(crate) tab_bar_rect: Option<egui::Rect>,
pub(crate) menu_bar_stable_until: Option<std::time::Instant>, pub(crate) menu_bar_stable_until: Option<std::time::Instant>,
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) find_matches: Vec<(usize, usize)>, // (start_pos, end_pos) byte positions
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) prev_show_find: bool, // Track previous state to detect transitions
// Width calculation cache and state tracking
pub(crate) cached_width: Option<f32>,
pub(crate) last_word_wrap: bool,
pub(crate) last_show_line_numbers: bool,
pub(crate) last_font_size: f32,
pub(crate) last_line_side: bool,
pub(crate) last_viewport_width: f32,
// pub(crate) vim_mode: bool,
// Cursor tracking for smart scrolling // Cursor tracking for smart scrolling
pub(crate) previous_cursor_position: Option<usize>, pub(crate) previous_cursor_position: Option<usize>,
// Track previous content for incremental processing
pub(crate) previous_content: String,
pub(crate) previous_cursor_char_index: Option<usize>,
pub(crate) current_cursor_line: usize, // Track current line number incrementally
pub(crate) previous_cursor_line: usize, // Track previous line for comparison
pub(crate) font_settings_changed: bool, // Flag to trigger text reprocessing when font changes
} }

View File

@ -79,7 +79,7 @@ impl TextEditor {
ui.add_space(8.0); ui.add_space(8.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(format!("{file}")).size(18.0).weak());
} }
ui.add_space(12.0); ui.add_space(12.0);

View File

@ -1,63 +1,331 @@
use std::sync::Arc;
use std::thread;
use super::editor::{TextEditor, TextProcessingResult}; use super::editor::{TextEditor, TextProcessingResult};
use eframe::egui;
impl TextEditor { impl TextEditor {
pub fn start_text_processing_thread(&mut self) { /// Process text content and find the longest line (only used for initial scan)
let _processing_result = Arc::clone(&self.text_processing_result); pub fn process_text_for_rendering(&mut self, content: &str, ui: &egui::Ui) {
let lines: Vec<&str> = content.lines().collect();
let line_count = lines.len().max(1);
let handle = thread::Builder::new() if lines.is_empty() {
.name("TextProcessor".to_string()) self.update_processing_result(TextProcessingResult {
.spawn(move || { line_count: 1,
// Set thread priority to high (platform-specific) longest_line_index: 0,
#[cfg(target_os = "linux")] longest_line_length: 0,
{ longest_line_pixel_width: 0.0,
unsafe {
let thread_id = libc::pthread_self();
let mut param: libc::sched_param = std::mem::zeroed();
param.sched_priority = 50;
let _ = libc::pthread_setschedparam(thread_id, libc::SCHED_OTHER, &param);
}
}
}); });
return;
}
match handle { let mut longest_line_index = 0;
Ok(h) => self.processing_thread_handle = Some(h), let mut longest_line_length = 0;
Err(e) => eprintln!("Failed to start text processing thread: {}", e),
for (index, line) in lines.iter().enumerate() {
let char_count = line.chars().count();
if char_count > longest_line_length {
longest_line_length = char_count;
longest_line_index = index;
} }
} }
pub fn process_text_for_rendering( let font_id = self.get_font_id();
&mut self, let longest_line_pixel_width = if longest_line_length > 0 {
content: &str, let longest_line_text = lines[longest_line_index];
available_width: f32, ui.fonts(|fonts| {
) { fonts
let line_count = content.lines().count().max(1); .layout(
longest_line_text.to_string(),
let visual_line_mapping = if self.word_wrap { font_id,
// For now, simplified mapping - this could be moved to background thread egui::Color32::WHITE,
(1..=line_count).map(Some).collect() f32::INFINITY,
)
.size()
.x
})
} else { } else {
(1..=line_count).map(Some).collect() 0.0
}; };
let result = TextProcessingResult { let result = TextProcessingResult {
line_count, line_count,
visual_line_mapping, longest_line_index,
max_line_length: available_width, longest_line_length,
_processed_content: content.to_string(), longest_line_pixel_width,
}; };
if let Ok(mut processing_result) = self.text_processing_result.lock() { self.update_processing_result(result);
*processing_result = result; }
/// Efficiently detect and process line changes without full content iteration
pub fn process_incremental_change(
&mut self,
old_content: &str,
new_content: &str,
old_cursor_pos: usize,
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,
new_content,
old_cursor_pos,
new_cursor_pos,
ui,
);
} else if new_content.len() > old_content.len() {
self.handle_content_addition(
old_content,
new_content,
old_cursor_pos,
new_cursor_pos,
ui,
);
} else {
self.handle_content_removal(
old_content,
new_content,
old_cursor_pos,
new_cursor_pos,
ui,
);
}
self.previous_cursor_line = self.current_cursor_line;
}
/// Calculate the change in cursor line without full iteration
fn calculate_cursor_line_change(
&self,
old_content: &str,
new_content: &str,
old_cursor_pos: usize,
new_cursor_pos: usize,
) -> isize {
let old_newlines = old_content[..old_cursor_pos.min(old_content.len())]
.bytes()
.filter(|&b| b == b'\n')
.count();
let new_newlines = new_content[..new_cursor_pos.min(new_content.len())]
.bytes()
.filter(|&b| b == b'\n')
.count();
new_newlines as isize - old_newlines as isize
}
/// Handle character replacement (same length change)
fn handle_character_replacement(
&mut self,
_old_content: &str,
new_content: &str,
_old_cursor_pos: usize,
new_cursor_pos: usize,
ui: &egui::Ui,
) {
let current_line = self.extract_current_line(new_content, new_cursor_pos);
let current_line_length = current_line.chars().count();
self.update_line_if_longer(
self.current_cursor_line,
&current_line,
current_line_length,
ui,
);
}
/// Handle content addition
fn handle_content_addition(
&mut self,
old_content: &str,
new_content: &str,
_old_cursor_pos: usize,
new_cursor_pos: usize,
ui: &egui::Ui,
) {
let min_len = old_content.len().min(new_content.len());
let mut common_prefix = 0;
let mut common_suffix = 0;
for i in 0..min_len {
if old_content.as_bytes()[i] == new_content.as_bytes()[i] {
common_prefix += 1;
} else {
break;
} }
} }
for i in 0..min_len - common_prefix {
let old_idx = old_content.len() - 1 - i;
let new_idx = new_content.len() - 1 - i;
if old_content.as_bytes()[old_idx] == new_content.as_bytes()[new_idx] {
common_suffix += 1;
} else {
break;
}
}
let added_start = common_prefix;
let added_end = new_content.len() - common_suffix;
let added_text = &new_content[added_start..added_end];
let newlines_added = added_text.bytes().filter(|&b| b == b'\n').count();
if newlines_added > 0 {
let mut current_result = self.get_text_processing_result();
current_result.line_count += newlines_added;
self.update_processing_result(current_result);
}
let current_line = self.extract_current_line(new_content, new_cursor_pos);
let current_line_length = current_line.chars().count();
self.update_line_if_longer(
self.current_cursor_line,
&current_line,
current_line_length,
ui,
);
}
/// Handle content removal
fn handle_content_removal(
&mut self,
old_content: &str,
new_content: &str,
_old_cursor_pos: usize,
new_cursor_pos: usize,
ui: &egui::Ui,
) {
let min_len = old_content.len().min(new_content.len());
let mut common_prefix = 0;
let mut common_suffix = 0;
for i in 0..min_len {
if old_content.as_bytes()[i] == new_content.as_bytes()[i] {
common_prefix += 1;
} else {
break;
}
}
for i in 0..min_len - common_prefix {
let old_idx = old_content.len() - 1 - i;
let new_idx = new_content.len() - 1 - i;
if old_content.as_bytes()[old_idx] == new_content.as_bytes()[new_idx] {
common_suffix += 1;
} else {
break;
}
}
let removed_start = common_prefix;
let removed_end = old_content.len() - common_suffix;
let removed_text = &old_content[removed_start..removed_end];
let newlines_removed = removed_text.bytes().filter(|&b| b == b'\n').count();
if newlines_removed > 0 {
let mut current_result = self.get_text_processing_result();
current_result.line_count = current_result.line_count.saturating_sub(newlines_removed);
if self.current_cursor_line <= current_result.longest_line_index {
self.process_text_for_rendering(new_content, ui);
}
self.update_processing_result(current_result);
}
let current_line = self.extract_current_line(new_content, new_cursor_pos);
let current_line_length = current_line.chars().count();
let current_result = self.get_text_processing_result();
if self.current_cursor_line == current_result.longest_line_index
&& current_line_length < current_result.longest_line_length
{
self.process_text_for_rendering(new_content, ui);
} else {
self.update_line_if_longer(
self.current_cursor_line,
&current_line,
current_line_length,
ui,
);
}
}
/// Extract the current line efficiently without full content scan
fn extract_current_line(&self, content: &str, cursor_pos: usize) -> String {
let bytes = content.as_bytes();
let mut line_start = cursor_pos;
while line_start > 0 && bytes[line_start - 1] != b'\n' {
line_start -= 1;
}
let mut line_end = cursor_pos;
while line_end < bytes.len() && bytes[line_end] != b'\n' {
line_end += 1;
}
content[line_start..line_end].to_string()
}
/// Update longest line info if the current line is longer
fn update_line_if_longer(
&mut self,
line_index: usize,
line_content: &str,
line_length: usize,
ui: &egui::Ui,
) {
let current_result = self.get_text_processing_result();
if line_length > current_result.longest_line_length {
let font_id = self.get_font_id();
let pixel_width = ui.fonts(|fonts| {
fonts
.layout(
line_content.to_string(),
font_id,
egui::Color32::WHITE,
f32::INFINITY,
)
.size()
.x
});
let result = TextProcessingResult {
line_count: current_result.line_count,
longest_line_index: line_index,
longest_line_length: line_length,
longest_line_pixel_width: pixel_width,
};
self.update_processing_result(result);
}
}
/// 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.clone())
.unwrap_or_default() .unwrap_or_default()
} }
/// Update the processing result atomically
fn update_processing_result(&self, result: TextProcessingResult) {
if let Ok(mut processing_result) = self.text_processing_result.lock() {
*processing_result = result;
}
}
} }

View File

@ -12,14 +12,19 @@ impl TextEditor {
pub fn get_title(&self) -> String { pub fn get_title(&self) -> String {
if let Some(tab) = self.get_active_tab() { if let Some(tab) = self.get_active_tab() {
let modified_indicator = if tab.is_modified { "*" } else { "" }; let modified_indicator = if tab.is_modified { "*" } else { "" };
format!("{}{} - C-Text", tab.title, modified_indicator) format!(
"{}{} - {}",
tab.title,
modified_indicator,
env!("CARGO_PKG_NAME")
)
} else { } else {
"C-Text".to_string() format!("{} - {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
} }
} }
/// Get the configured font ID based on the editor's font settings /// Get the configured font ID based on the editor's font settings
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,
_ => egui::FontFamily::Proportional, _ => egui::FontFamily::Proportional,
@ -27,11 +32,13 @@ 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,
@ -45,9 +52,27 @@ impl TextEditor {
); );
ctx.set_style(style); ctx.set_style(style);
self.font_settings_changed = true;
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 /// 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();
@ -60,11 +85,9 @@ impl TextEditor {
}; };
} }
// Get line count from processing result
let processing_result = self.get_text_processing_result(); let processing_result = self.get_text_processing_result();
let line_count = processing_result.line_count; let line_count = processing_result.line_count;
// Calculate base line number width
let font_id = self.get_font_id(); let font_id = self.get_font_id();
let line_count_digits = line_count.to_string().len(); let line_count_digits = line_count.to_string().len();
let sample_text = "9".repeat(line_count_digits); let sample_text = "9".repeat(line_count_digits);
@ -75,11 +98,10 @@ impl TextEditor {
.x .x
}); });
// Add padding based on line_side setting
let line_number_width = if self.line_side { let line_number_width = if self.line_side {
base_line_number_width + 20.0 // Extra padding when line numbers are on the side base_line_number_width + 20.0
} else { } else {
base_line_number_width + 8.0 // Minimal padding when line numbers are normal base_line_number_width + 8.0
}; };
// Separator space (7.0 for separator + 3.0 spacing = 10.0 total) // Separator space (7.0 for separator + 3.0 spacing = 10.0 total)
@ -95,68 +117,17 @@ impl TextEditor {
} }
} }
/// Calculate the available width for non-word-wrapped content based on content analysis /// 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 {
if let Some(active_tab) = self.get_active_tab() { let processing_result = self.get_text_processing_result();
let content = &active_tab.content;
if content.is_empty() { if processing_result.longest_line_length == 0 {
return self.calculate_editor_dimensions(ui).text_width; return self.calculate_editor_dimensions(ui).text_width;
} }
// Find the longest line let longest_line_width = processing_result.longest_line_pixel_width + self.font_size;
let longest_line = content
.lines()
.max_by_key(|line| line.chars().count())
.unwrap_or("");
if longest_line.is_empty() {
return self.calculate_editor_dimensions(ui).text_width;
}
// Calculate the width needed for the longest line
let font_id = self.get_font_id();
let longest_line_width = ui.fonts(|fonts| {
fonts.layout(
longest_line.to_string(),
font_id,
egui::Color32::WHITE,
f32::INFINITY,
).size().x
}) + 20.0; // Add some padding
// Return the larger of the calculated width or minimum available width
let dimensions = self.calculate_editor_dimensions(ui); let dimensions = self.calculate_editor_dimensions(ui);
longest_line_width.max(dimensions.text_width) longest_line_width.max(dimensions.text_width)
} else {
self.calculate_editor_dimensions(ui).text_width
}
}
/// Check if width calculation needs to be performed based on parameter changes
pub fn needs_width_calculation(&self, ctx: &egui::Context) -> bool {
let current_viewport_width = ctx.available_rect().width();
self.cached_width.is_none() ||
self.word_wrap != self.last_word_wrap ||
self.show_line_numbers != self.last_show_line_numbers ||
(self.font_size - self.last_font_size).abs() > 0.1 ||
self.line_side != self.last_line_side ||
(current_viewport_width - self.last_viewport_width).abs() > 1.0
}
/// Update the cached width calculation state
pub fn update_width_calculation_state(&mut self, ctx: &egui::Context, width: f32) {
self.cached_width = Some(width);
self.last_word_wrap = self.word_wrap;
self.last_show_line_numbers = self.show_line_numbers;
self.last_font_size = self.font_size;
self.last_line_side = self.line_side;
self.last_viewport_width = ctx.available_rect().width();
}
/// Get cached width if available, otherwise return None to indicate calculation is needed
pub fn get_cached_width(&self) -> Option<f32> {
self.cached_width
} }
} }

View File

@ -15,7 +15,7 @@ pub struct Tab {
pub file_path: Option<PathBuf>, pub file_path: Option<PathBuf>,
pub is_modified: bool, pub is_modified: bool,
pub title: String, pub title: String,
hasher: DefaultHasher, pub hasher: DefaultHasher,
} }
impl Tab { impl Tab {
@ -29,7 +29,7 @@ impl Tab {
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, hasher,
} }
} }

View File

@ -77,7 +77,7 @@ fn get_pywal_colors() -> Option<egui::Visuals> {
let fg = parse_color(colors.get(7).unwrap_or(&colors[0]))?; let fg = parse_color(colors.get(7).unwrap_or(&colors[0]))?;
let bg_alt = parse_color(colors.get(8).unwrap_or(&colors[0]))?; let bg_alt = parse_color(colors.get(8).unwrap_or(&colors[0]))?;
let accent = parse_color(colors.get(1).unwrap_or(&colors[0]))?; let accent = parse_color(colors.get(1).unwrap_or(&colors[0]))?;
let secondary = parse_color(colors.get(2).unwrap_or(&colors[0]))?; let _secondary = parse_color(colors.get(2).unwrap_or(&colors[0]))?;
let mut visuals = if is_dark_color(bg) { let mut visuals = if is_dark_color(bg) {
egui::Visuals::dark() egui::Visuals::dark()

View File

@ -43,7 +43,7 @@ pub(crate) fn open_file(app: &mut TextEditor) {
} }
} }
Err(err) => { Err(err) => {
eprintln!("Failed to open file: {}", err); eprintln!("Failed to open file: {err}");
} }
} }
} }
@ -81,7 +81,7 @@ pub(crate) fn save_to_path(app: &mut TextEditor, path: PathBuf) {
active_tab.mark_as_saved(); active_tab.mark_as_saved();
} }
Err(err) => { Err(err) => {
eprintln!("Failed to save file: {}", err); eprintln!("Failed to save file: {err}");
} }
} }
} }

View File

@ -5,21 +5,21 @@ use eframe::egui;
mod app; mod app;
mod io; mod io;
mod ui; mod ui;
use app::{TextEditor, config::Config}; use app::{config::Config, TextEditor};
fn main() -> eframe::Result { fn main() -> eframe::Result {
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])
.with_title("C-Ext") .with_title("ced")
.with_app_id("io.lampnet.c-ext"), .with_app_id("io.lampnet.ced"),
..Default::default() ..Default::default()
}; };
let config = Config::load(); let config = Config::load();
eframe::run_native( eframe::run_native(
"C-Ext", "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)))),
) )

View File

@ -14,7 +14,7 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
let line_side = app.line_side; let line_side = app.line_side;
let font_size = app.font_size; let font_size = app.font_size;
egui::CentralPanel::default() let _output = egui::CentralPanel::default()
.frame(egui::Frame::NONE) .frame(egui::Frame::NONE)
.show(ctx, |ui| { .show(ctx, |ui| {
let bg_color = ui.visuals().extreme_bg_color; let bg_color = ui.visuals().extreme_bg_color;
@ -23,11 +23,20 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
let editor_height = panel_rect.height(); let editor_height = panel_rect.height();
if !show_line_numbers || app.get_active_tab().is_none() { if !show_line_numbers || app.get_active_tab().is_none() {
let _scroll_response =
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false; 2]) .auto_shrink([false; 2])
.show(ui, |ui| { .show(ui, |ui| {
let full_rect = ui.available_rect_before_wrap();
let 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); editor_view_ui(ui, app);
}); });
show_context_menu(ui, app, &context_response);
});
return; return;
} }
@ -77,7 +86,8 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
.show(ui, |ui| { .show(ui, |ui| {
if line_side { if line_side {
// Line numbers on the right // Line numbers on the right
let text_editor_width = editor_dimensions.text_width + editor_dimensions.total_reserved_width; let text_editor_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),
@ -86,27 +96,111 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
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| {
// Create an invisible interaction area for context menu
let full_rect = ui.available_rect_before_wrap();
let context_response = ui.allocate_response(
full_rect.size(),
egui::Sense::click(),
);
// Reset cursor to render editor at the top
ui.scope_builder(
egui::UiBuilder::new().max_rect(full_rect),
|ui| { |ui| {
editor_view_ui(ui, app); editor_view_ui(ui, app);
}, },
); );
show_context_menu(ui, app, &context_response);
},
);
separator_widget(ui); separator_widget(ui);
line_numbers_widget(ui); line_numbers_widget(ui);
}, },
); );
} else { } else {
// Line numbers on the left // Line numbers on the left
let text_editor_width = editor_dimensions.text_width + editor_dimensions.total_reserved_width; let text_editor_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| {
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 context_response =
ui.allocate_response(editor_area.size(), egui::Sense::click());
// Reset cursor to render editor at the current position
ui.scope_builder(
egui::UiBuilder::new().max_rect(editor_area),
|ui| {
editor_view_ui(ui, app); editor_view_ui(ui, app);
}, },
); );
show_context_menu(ui, app, &context_response);
},
);
} }
}); });
}); });
} }
fn show_context_menu(_ui: &mut egui::Ui, app: &mut TextEditor, context_response: &egui::Response) {
context_response.context_menu(|ui| {
let text_len = app.get_active_tab().unwrap().content.len();
let reset_zoom_key = egui::Id::new("editor_reset_zoom");
if ui.button("Cut").clicked() {
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::RequestCut);
ui.close_menu();
}
if ui.button("Copy").clicked() {
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::RequestCopy);
ui.close_menu();
}
if ui.button("Paste").clicked() {
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::RequestPaste);
ui.close_menu();
}
if ui.button("Delete").clicked() {
ui.ctx().input_mut(|i| {
i.events.push(egui::Event::Key {
key: egui::Key::Delete,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
})
});
ui.close_menu();
}
if ui.button("Select All").clicked() {
let text_edit_id = egui::Id::new("main_text_editor");
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) {
let select_all_range = egui::text::CCursorRange::two(
egui::text::CCursor::new(0),
egui::text::CCursor::new(text_len),
);
state.cursor.set_char_range(Some(select_all_range));
egui::TextEdit::store_state(ui.ctx(), text_edit_id, state);
}
ui.close_menu();
}
ui.separator();
if ui.button("Reset Zoom").clicked() {
ui.ctx().memory_mut(|mem| {
mem.data.insert_temp(reset_zoom_key, true);
});
ui.close_menu();
}
});
}

View File

@ -1,28 +1,21 @@
use crate::app::TextEditor; use crate::app::TextEditor;
use eframe::egui; use eframe::egui;
use super::find_highlight::draw_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();
pub(super) fn editor_view(
ui: &mut egui::Ui,
app: &mut TextEditor,
) -> (egui::Response, Option<egui::Rect>) {
let current_match_position = app.get_current_match_position();
let show_find = app.show_find; let show_find = app.show_find;
let prev_show_find = app.prev_show_find; let _prev_show_find = app.prev_show_find;
let show_preferences = app.show_preferences; let show_preferences = app.show_preferences;
let show_about = app.show_about; let show_about = app.show_about;
let show_shortcuts = app.show_shortcuts; let show_shortcuts = app.show_shortcuts;
let word_wrap = app.word_wrap; let word_wrap = app.word_wrap;
let font_size = app.font_size; let font_size = app.font_size;
// Check if reset zoom was requested in previous frame
let reset_zoom_key = egui::Id::new("editor_reset_zoom"); let reset_zoom_key = egui::Id::new("editor_reset_zoom");
let should_reset_zoom = ui.ctx().memory_mut(|mem| { let should_reset_zoom = ui
mem.data.get_temp::<bool>(reset_zoom_key).unwrap_or(false) .ctx()
}); .memory_mut(|mem| mem.data.get_temp::<bool>(reset_zoom_key).unwrap_or(false));
// Reset zoom if requested
if should_reset_zoom { if should_reset_zoom {
app.zoom_factor = 1.0; app.zoom_factor = 1.0;
ui.ctx().set_zoom_factor(1.0); ui.ctx().set_zoom_factor(1.0);
@ -31,7 +24,16 @@ pub(super) fn editor_view(
}); });
} }
if let Some(active_tab) = app.get_active_tab_mut() { let estimated_width = if !word_wrap {
app.calculate_content_based_width(ui)
} else {
0.0
};
let Some(active_tab) = app.get_active_tab_mut() else {
return ui.label("No file open, how did you get here?");
};
let bg_color = ui.visuals().extreme_bg_color; let bg_color = ui.visuals().extreme_bg_color;
let editor_rect = ui.available_rect_before_wrap(); let editor_rect = ui.available_rect_before_wrap();
ui.painter().rect_filled(editor_rect, 0.0, bg_color); ui.painter().rect_filled(editor_rect, 0.0, bg_color);
@ -52,65 +54,96 @@ pub(super) fn editor_view(
.cursor_at_end(false) .cursor_at_end(false)
.id(egui::Id::new("main_text_editor")); .id(egui::Id::new("main_text_editor"));
let output = text_edit.show(ui); let output = if word_wrap {
text_edit.show(ui)
// Store text length for context menu } else {
let text_len = active_tab.content.len(); egui::ScrollArea::horizontal()
.auto_shrink([false; 2])
// Right-click context menu .show(ui, |ui| {
output.response.context_menu(|ui| { ui.allocate_ui_with_layout(
if ui.button("Cut").clicked() { egui::Vec2::new(estimated_width, ui.available_height()),
ui.ctx().input_mut(|i| i.events.push(egui::Event::Cut)); egui::Layout::left_to_right(egui::Align::TOP),
ui.close_menu(); |ui| text_edit.show(ui),
} )
if ui.button("Copy").clicked() {
ui.ctx().input_mut(|i| i.events.push(egui::Event::Copy));
ui.close_menu();
}
if ui.button("Paste").clicked() {
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::RequestPaste);
ui.close_menu();
}
if ui.button("Delete").clicked() {
ui.ctx().input_mut(|i| {
i.events.push(egui::Event::Key {
key: egui::Key::Delete,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
}) })
}); .inner
ui.close_menu(); .inner
} };
if ui.button("Select All").clicked() {
let text_edit_id = egui::Id::new("main_text_editor"); let content_changed = output.response.changed();
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { let content_for_processing = if content_changed {
let select_all_range = egui::text::CCursorRange::two( active_tab.update_modified_state();
egui::text::CCursor::new(0), Some(active_tab.content.clone())
egui::text::CCursor::new(text_len), } else {
None
};
let current_cursor_pos = output
.state
.cursor
.char_range()
.map(|range| range.primary.index);
if let Some(content) = content_for_processing {
let previous_content = app.previous_content.clone();
let previous_cursor_pos = app.previous_cursor_char_index;
if !previous_content.is_empty() {
if let (Some(prev_cursor_pos), Some(curr_cursor_pos)) =
(previous_cursor_pos, current_cursor_pos)
{
app.process_incremental_change(
&previous_content,
&content,
prev_cursor_pos,
curr_cursor_pos,
ui,
); );
state.cursor.set_char_range(Some(select_all_range)); } else {
egui::TextEdit::store_state(ui.ctx(), text_edit_id, state); app.process_text_for_rendering(&content, ui);
}
ui.close_menu();
}
ui.separator();
if ui.button("Reset Zoom").clicked() {
ui.ctx().memory_mut(|mem| {
mem.data.insert_temp(reset_zoom_key, true);
});
ui.close_menu();
}
});
let cursor_rect = if let Some(cursor_range) = output.state.cursor.char_range() { if let Some(cursor_pos) = current_cursor_pos {
let cursor_pos = cursor_range.primary.index; app.current_cursor_line = content[..cursor_pos]
.bytes()
.filter(|&b| b == b'\n')
.count();
}
}
}
app.previous_content = content.clone();
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 {
if let Some(active_tab) = app.get_active_tab() {
let content = active_tab.content.clone();
if !content.is_empty() {
app.process_text_for_rendering(&content, ui);
}
}
app.font_settings_changed = false;
}
if !word_wrap {
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 content = &active_tab.content;
let cursor_line = content
let text_up_to_cursor = &content[..cursor_pos.min(content.len())]; .char_indices()
let cursor_line = text_up_to_cursor.chars().filter(|&c| c == '\n').count(); .take_while(|(byte_pos, _)| *byte_pos < cursor_pos)
.filter(|(_, ch)| *ch == '\n')
.count();
let font_id = ui let font_id = ui
.style() .style()
@ -125,44 +158,19 @@ pub(super) fn editor_view(
egui::pos2(output.response.rect.left(), y_pos), egui::pos2(output.response.rect.left(), y_pos),
egui::vec2(2.0, line_height), egui::vec2(2.0, line_height),
); );
Some(cursor_rect)
} else {
None
};
if !show_find && prev_show_find { let visible_area = ui.clip_rect();
if let Some((start_pos, end_pos)) = current_match_position { if !visible_area.intersects(cursor_rect) {
let text_edit_id = egui::Id::new("main_text_editor"); ui.scroll_to_rect(cursor_rect, Some(egui::Align::Center));
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) {
let cursor_range = egui::text::CCursorRange::two(
egui::text::CCursor::new(start_pos),
egui::text::CCursor::new(end_pos),
);
state.cursor.set_char_range(Some(cursor_range));
egui::TextEdit::store_state(ui.ctx(), text_edit_id, state);
} }
} }
} }
if show_find { app.previous_cursor_position = Some(cursor_pos);
if let Some((start_pos, end_pos)) = current_match_position {
draw_find_highlight(
ui,
&active_tab.content,
start_pos,
end_pos,
output.response.rect,
font_size,
);
} }
} }
if output.response.changed() { // Request focus if no dialogs are open
active_tab.update_modified_state();
app.find_matches.clear();
app.current_match_index = None;
}
if !output.response.has_focus() if !output.response.has_focus()
&& !show_preferences && !show_preferences
&& !show_about && !show_about
@ -171,52 +179,6 @@ pub(super) fn editor_view(
{ {
output.response.request_focus(); output.response.request_focus();
} }
(output.response, cursor_rect)
} else { output.response
(ui.label("No file open, how did you get here?"), None)
}
}
pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) {
let word_wrap = app.word_wrap;
if word_wrap {
let (_response, _cursor_rect) = editor_view(ui, app);
} else {
let estimated_width = app.calculate_content_based_width(ui);
let output = egui::ScrollArea::horizontal()
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.allocate_ui_with_layout(
egui::Vec2::new(estimated_width, ui.available_height()),
egui::Layout::left_to_right(egui::Align::TOP),
|ui| editor_view(ui, app),
)
});
let editor_response = &output.inner.inner.0;
if let Some(cursor_rect) = output.inner.inner.1 {
let text_edit_id = egui::Id::new("main_text_editor");
let current_cursor_pos =
if let Some(state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) {
state.cursor.char_range().map(|range| range.primary.index)
} else {
None
};
let cursor_moved = current_cursor_pos != app.previous_cursor_position;
let text_changed = editor_response.changed();
let should_scroll = (cursor_moved || text_changed)
&& {
let visible_area = ui.clip_rect();
!visible_area.intersects(cursor_rect)
};
if should_scroll {
ui.scroll_to_rect(cursor_rect, Some(egui::Align::Center));
}
app.previous_cursor_position = current_cursor_pos;
}
}
} }

View File

@ -1,6 +1,6 @@
use eframe::egui; use eframe::egui;
pub(super) fn draw_find_highlight( pub(super) fn _draw_find_highlight(
ui: &mut egui::Ui, ui: &mut egui::Ui,
content: &str, content: &str,
start_pos: usize, start_pos: usize,
@ -74,10 +74,7 @@ pub(super) fn draw_find_highlight(
egui::vec2(match_width, line_height), egui::vec2(match_width, line_height),
); );
ui.painter().rect_filled( ui.painter()
highlight_rect, .rect_filled(highlight_rect, 0.0, ui.visuals().selection.bg_fill);
0.0,
ui.visuals().selection.bg_fill,
);
} }
} }

View File

@ -86,8 +86,7 @@ pub(super) fn render_line_numbers(
let bg_color = ui.visuals().extreme_bg_color; let bg_color = ui.visuals().extreme_bg_color;
let line_numbers_rect = ui.available_rect_before_wrap(); let line_numbers_rect = ui.available_rect_before_wrap();
ui.painter() ui.painter().rect_filled(line_numbers_rect, 0.0, bg_color);
.rect_filled(line_numbers_rect, 0.0, bg_color);
let font_id = egui::FontId::monospace(font_size); let font_id = egui::FontId::monospace(font_size);
let line_count_width = line_count.to_string().len(); let line_count_width = line_count.to_string().len();

View File

@ -11,12 +11,12 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
let should_show_menubar = !app.auto_hide_toolbar || { let should_show_menubar = !app.auto_hide_toolbar || {
if app.menu_interaction_active { if app.menu_interaction_active {
app.menu_bar_stable_until = Some(now + std::time::Duration::from_millis(500)); app.menu_bar_stable_until = Some(now + std::time::Duration::from_millis(16));
true true
} else if should_stay_stable { } else if should_stay_stable {
true true
} else if let Some(pointer_pos) = ctx.pointer_hover_pos() { } else if let Some(pointer_pos) = ctx.pointer_hover_pos() {
let in_menu_trigger_area = pointer_pos.y < 10.0; let in_menu_trigger_area = pointer_pos.y < 5.0;
if in_menu_trigger_area { if in_menu_trigger_area {
app.menu_bar_stable_until = Some(now + std::time::Duration::from_millis(300)); app.menu_bar_stable_until = Some(now + std::time::Duration::from_millis(300));
@ -172,14 +172,18 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
ui.menu_button("View", |ui| { ui.menu_button("View", |ui| {
app.menu_interaction_active = true; app.menu_interaction_active = true;
if ui if ui
.checkbox(&mut app.show_line_numbers, "Toggle Line Numbers") .checkbox(&mut app.show_line_numbers, "Show Line Numbers")
.clicked() .clicked()
{ {
app.save_config(); app.save_config();
ui.close_menu(); ui.close_menu();
} }
if ui.checkbox(&mut app.word_wrap, "Word Wrap").clicked() {
app.save_config();
ui.close_menu();
}
if ui if ui
.checkbox(&mut app.word_wrap, "Toggle Word Wrap") .checkbox(&mut app.auto_hide_tab_bar, "Hide Tab Bar")
.clicked() .clicked()
{ {
app.save_config(); app.save_config();
@ -265,6 +269,48 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
ui.close_menu(); ui.close_menu();
} }
}); });
if app.auto_hide_tab_bar {
let tab_title = if let Some(tab) = app.get_active_tab() {
tab.title.clone()
} else {
let empty_tab = crate::app::tab::Tab::new_empty(1);
empty_tab.title.clone()
};
let window_width = ctx.screen_rect().width();
let font_id = ui.style().text_styles[&egui::TextStyle::Body].clone();
let tab_title = if app.get_active_tab().is_some_and(|tab| tab.is_modified) {
format!("{tab_title}*")
} else {
tab_title
};
let text_galley = ui.fonts(|fonts| {
fonts.layout_job(egui::text::LayoutJob::simple_singleline(
tab_title,
font_id,
ui.style().visuals.text_color(),
))
});
let text_width = text_galley.size().x;
let text_height = text_galley.size().y;
let window_center_x = window_width / 2.0;
let text_x = (window_center_x - text_width / 2.0).max(0.0);
let cursor_pos = ui.cursor().left_top();
ui.painter().galley(
egui::pos2(text_x, cursor_pos.y),
text_galley,
ui.style().visuals.text_color(),
);
ui.allocate_exact_size(egui::vec2(0.0, text_height), egui::Sense::hover());
}
}); });
}); });
} }

View File

@ -58,7 +58,7 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
}); });
if changed { if changed {
app.apply_font_settings(ctx); app.apply_font_settings_with_ui(ctx, ui);
} }
}); });
@ -72,7 +72,11 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
app.font_size_input = Some(app.font_size.to_string()); app.font_size_input = Some(app.font_size.to_string());
} }
let mut font_size_text = app.font_size_input.as_ref().unwrap().clone(); let mut font_size_text = app
.font_size_input
.as_ref()
.unwrap_or(&"14".to_string())
.clone();
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(50.0)
@ -93,7 +97,7 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
let clamped_size = new_size.clamp(8.0, 32.0); let clamped_size = new_size.clamp(8.0, 32.0);
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(ctx); app.apply_font_settings_with_ui(ctx, ui);
} }
} }
app.font_size_input = None; app.font_size_input = None;
@ -123,7 +127,9 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
}, },
); );
ui.label( ui.label(
egui::RichText::new("The quick brown fox jumps over the lazy dog.") egui::RichText::new(
"The quick brown fox jumps over the lazy dog.",
)
.font(preview_font.clone()), .font(preview_font.clone()),
); );
ui.label( ui.label(
@ -134,7 +140,9 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
egui::RichText::new("abcdefghijklmnopqrstuvwxyz") egui::RichText::new("abcdefghijklmnopqrstuvwxyz")
.font(preview_font.clone()), .font(preview_font.clone()),
); );
ui.label(egui::RichText::new("1234567890 !@#$%^&*()").font(preview_font)); ui.label(
egui::RichText::new("1234567890 !@#$%^&*()").font(preview_font),
);
}); });
}); });

View File

@ -27,9 +27,7 @@ fn render_shortcuts_content(ui: &mut egui::Ui) {
ui.separator(); ui.separator();
ui.label(egui::RichText::new("Views").size(18.0).strong()); ui.label(egui::RichText::new("Views").size(18.0).strong());
ui.label(egui::RichText::new("Ctrl + L: Toggle Line Numbers").size(14.0)); ui.label(egui::RichText::new("Ctrl + L: Toggle Line Numbers").size(14.0));
ui.label( ui.label(egui::RichText::new("Ctrl + Shift + L: Change Line Number Side").size(14.0));
egui::RichText::new("Ctrl + Shift + L: Change Line Number Side").size(14.0),
);
ui.label(egui::RichText::new("Ctrl + K: Toggle Word Wrap").size(14.0)); ui.label(egui::RichText::new("Ctrl + K: Toggle Word Wrap").size(14.0));
ui.label(egui::RichText::new("Ctrl + H: Toggle Auto Hide Toolbar").size(14.0)); ui.label(egui::RichText::new("Ctrl + 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 + P: Preferences").size(14.0));
@ -53,8 +51,8 @@ pub(crate) fn shortcuts_window(app: &mut TextEditor, ctx: &egui::Context) {
let screen_rect = ctx.screen_rect(); let screen_rect = ctx.screen_rect();
// Calculate appropriate window size that always fits nicely in the main window // Calculate appropriate window size that always fits nicely in the main window
let window_width = (screen_rect.width() * 0.6).min(400.0).max(300.0); let window_width = (screen_rect.width() * 0.6).clamp(300.0, 400.0);
let window_height = (screen_rect.height() * 0.7).min(500.0).max(250.0); let window_height = (screen_rect.height() * 0.7).clamp(250.0, 500.0);
egui::Window::new("Shortcuts") egui::Window::new("Shortcuts")
.collapsible(false) .collapsible(false)