Compare commits

..

No commits in common. "release" and "0.0.3" have entirely different histories.

34 changed files with 864 additions and 2483 deletions

1
.gitignore vendored
View File

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

View File

@ -1,19 +1,13 @@
[package]
name = "ced"
version = "0.1.3"
version = "0.0.3"
edition = "2024"
[dependencies]
eframe = "0.32"
egui = "0.32"
egui_extras = { version = "0.32", features = ["syntect"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.141"
rfd = "0.15.4"
toml = "0.9.2"
dirs = "6.0"
libc = "0.2.174"
syntect = "5.2.0"
plist = "1.7.4"
diffy = "0.4.2"
uuid = { version = "1.0", features = ["v4"] }
eframe = "0.31"
egui = "0.31"
serde = { version = "1.0", features = ["derive"] }
rfd = "0.15"
toml = "0.8"
dirs = "5.0"
libc = "0.2"

View File

@ -1,25 +0,0 @@
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.

View File

@ -1,78 +0,0 @@
# 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.).
* Choose between a fresh start each time you open, or maintaining a consistent state.
* Separate UI zoom that doesn't affect font size (`Ctrl+Shift` + `+`/`-`).
* 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
state_cache = true
auto_hide_toolbar = false
show_line_numbers = false
word_wrap = false
theme = "System"
line_side = false
font_family = "Monospace"
font_size = 16.0
syntax_highlighting = true
```
### Options
| Option | Default | Description |
|--------|---------|-------------|
| `state_cache` | `false` | If `true`, opened files will remain opened with their unsaved changes when running the application again. |
| `auto_hide_toolbar` | `false` | If `true`, the menu bar at the top will be hidden. Move your mouse to the top of the window to reveal it. |
| `hide_tab_bar` | 'true' | If `false`, a separate tab bar will be drawn below the toolbar. |
| `show_line_numbers` | `false` | If `true`, line numbers will be displayed on the side specified by `line_side`. |
| `syntax_highlighting` | `false` | If `true`, text will be highlighted based on detected language. |
| `line_side` | `false` | If `false`, line numbers are on the left. If `true`, they are on the right. |
| `word_wrap` | `false` | If `true`, lines will wrap when they reach the edge of the window. |
| `font_family` | `"Proportional"` | The font family used for the editor text. |
| `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 |
| ------- | ---- |
| **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

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

View File

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

View File

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

View File

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

View File

@ -1,69 +1,61 @@
use super::editor::TextEditor;
use crate::app::config::Config;
use crate::app::tab::Tab;
use crate::app::theme;
use crate::io;
use std::path::PathBuf;
impl TextEditor {
pub fn from_config(config: Config) -> Self {
Self {
state_cache: config.state_cache,
tabs: vec![Tab::new_empty(1)],
active_tab_index: 0,
tab_counter: 1,
show_about: false,
show_shortcuts: false,
show_find: false,
show_preferences: false,
pending_unsaved_action: None,
force_quit_confirmed: false,
clean_quit_requested: false,
show_line_numbers: config.show_line_numbers,
word_wrap: config.word_wrap,
auto_hide_toolbar: config.auto_hide_toolbar,
hide_tab_bar: config.hide_tab_bar,
theme: config.theme,
line_side: config.line_side,
font_family: config.font_family,
font_size: config.font_size,
syntax_highlighting: config.syntax_highlighting,
..Default::default()
font_size_input: None,
zoom_factor: 1.0,
menu_interaction_active: false,
tab_bar_rect: None,
menu_bar_stable_until: None,
text_processing_result: std::sync::Arc::new(std::sync::Mutex::new(Default::default())),
processing_thread_handle: None,
find_query: String::new(),
find_matches: Vec::new(),
current_match_index: None,
case_sensitive_search: false,
prev_show_find: false,
// Width calculation cache and state tracking
cached_width: None,
last_word_wrap: config.word_wrap,
last_show_line_numbers: config.show_line_numbers,
last_font_size: config.font_size,
last_line_side: config.line_side,
last_viewport_width: 0.0,
// vim_mode: config.vim_mode,
// Cursor tracking for smart scrolling
previous_cursor_position: None,
}
}
pub fn from_config_with_context(
config: Config,
cc: &eframe::CreationContext<'_>,
initial_paths: Vec<PathBuf>,
) -> Self {
pub fn from_config_with_context(config: Config, cc: &eframe::CreationContext<'_>) -> Self {
let mut editor = Self::from_config(config);
if let Err(e) = editor.load_state_cache() {
eprintln!("Failed to load state cache: {e}");
}
if !initial_paths.is_empty() {
let mut opened_any = false;
for path in initial_paths {
if path.is_file() {
match io::open_file_from_path(&mut editor, path.clone()) {
Ok(()) => opened_any = true,
Err(e) => eprintln!("Error opening file {}: {}", path.display(), e),
}
} else if path.is_dir() {
match io::open_files_from_directory(&mut editor, path.clone()) {
Ok(count) => {
opened_any = true;
println!("Opened {} files from directory {}", count, path.display());
}
Err(e) => eprintln!("Error opening directory {}: {}", path.display(), e),
}
} else {
eprintln!("Warning: Path does not exist: {}", path.display());
}
}
if opened_any {
editor.active_tab_index = editor.tabs.len().saturating_sub(1);
}
}
theme::apply(editor.theme, &cc.egui_ctx);
cc.egui_ctx.options_mut(|o| o.zoom_with_keyboard = false);
let mut style = (*cc.egui_ctx.style()).to_owned();
let mut style = (*cc.egui_ctx.style()).clone();
style
.text_styles
.insert(egui::TextStyle::Body, egui::FontId::proportional(16.0));
@ -80,21 +72,20 @@ impl TextEditor {
editor.apply_font_settings(&cc.egui_ctx);
editor.start_text_processing_thread();
editor
}
pub fn get_config(&self) -> Config {
Config {
state_cache: self.state_cache,
auto_hide_toolbar: self.auto_hide_toolbar,
show_line_numbers: self.show_line_numbers,
hide_tab_bar: self.hide_tab_bar,
word_wrap: self.word_wrap,
theme: self.theme,
line_side: self.line_side,
font_family: self.font_family.to_string(),
font_family: self.font_family.clone(),
font_size: self.font_size,
syntax_highlighting: self.syntax_highlighting,
// vim_mode: self.vim_mode,
}
}
@ -102,7 +93,7 @@ impl TextEditor {
pub fn save_config(&self) {
let config = self.get_config();
if let Err(e) = config.save() {
eprintln!("Failed to save configuration: {e}");
eprintln!("Failed to save configuration: {}", e);
}
}
}

View File

@ -8,7 +8,6 @@ impl Default for TextEditor {
Self {
tabs: vec![Tab::new_empty(1)],
active_tab_index: 0,
state_cache: false,
tab_counter: 1,
show_about: false,
show_shortcuts: false,
@ -20,8 +19,6 @@ impl Default for TextEditor {
show_line_numbers: false,
word_wrap: true,
auto_hide_toolbar: false,
hide_tab_bar: true,
syntax_highlighting: false,
theme: Theme::default(),
line_side: false,
font_family: "Proportional".to_string(),
@ -32,23 +29,24 @@ impl Default for TextEditor {
tab_bar_rect: None,
menu_bar_stable_until: None,
text_processing_result: Arc::new(Mutex::new(TextProcessingResult::default())),
_processing_thread_handle: None,
processing_thread_handle: None,
// Find functionality
find_query: String::new(),
replace_query: String::new(),
find_matches: Vec::new(),
current_match_index: None,
case_sensitive_search: false,
show_replace_section: false,
prev_show_find: false,
focus_find: false,
// Width calculation cache and state tracking
cached_width: None,
last_word_wrap: true,
last_show_line_numbers: false,
last_font_size: 14.0,
last_line_side: false,
last_viewport_width: 0.0,
// vim_mode: false,
// Cursor tracking for smart scrolling
previous_cursor_position: None,
previous_content: String::new(),
previous_cursor_char_index: None,
current_cursor_line: 0,
previous_cursor_line: 0,
font_settings_changed: false,
text_needs_processing: false,
should_select_current_match: false,
}
}
}

View File

@ -13,18 +13,18 @@ pub enum UnsavedAction {
#[derive(Clone)]
pub struct TextProcessingResult {
pub line_count: usize,
pub longest_line_index: usize,
pub longest_line_length: usize,
pub longest_line_pixel_width: f32,
pub visual_line_mapping: Vec<Option<usize>>,
pub max_line_length: f32,
pub _processed_content: String,
}
impl Default for TextProcessingResult {
fn default() -> Self {
Self {
line_count: 1,
longest_line_index: 0,
longest_line_length: 0,
longest_line_pixel_width: 0.0,
visual_line_mapping: vec![Some(1)],
max_line_length: 0.0,
_processed_content: String::new(),
}
}
}
@ -33,8 +33,7 @@ impl Default for TextProcessingResult {
pub struct TextEditor {
pub(crate) tabs: Vec<Tab>,
pub(crate) active_tab_index: usize,
pub(crate) tab_counter: usize,
pub(crate) state_cache: bool,
pub(crate) tab_counter: usize, // Counter for numbering new tabs
pub(crate) show_about: bool,
pub(crate) show_shortcuts: bool,
pub(crate) show_find: bool,
@ -45,8 +44,6 @@ pub struct TextEditor {
pub(crate) show_line_numbers: bool,
pub(crate) word_wrap: bool,
pub(crate) auto_hide_toolbar: bool,
pub(crate) hide_tab_bar: bool,
pub(crate) syntax_highlighting: bool,
pub(crate) theme: Theme,
pub(crate) line_side: bool,
pub(crate) font_family: String,
@ -57,21 +54,22 @@ pub struct TextEditor {
pub(crate) tab_bar_rect: Option<egui::Rect>,
pub(crate) menu_bar_stable_until: Option<std::time::Instant>,
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) replace_query: String,
pub(crate) find_matches: Vec<(usize, usize)>,
pub(crate) find_matches: Vec<(usize, usize)>, // (start_pos, end_pos) byte positions
pub(crate) current_match_index: Option<usize>,
pub(crate) case_sensitive_search: bool,
pub(crate) show_replace_section: bool,
pub(crate) prev_show_find: bool,
pub(crate) focus_find: bool,
pub(crate) previous_content: String,
pub(crate) previous_cursor_char_index: Option<usize>,
pub(crate) current_cursor_line: usize,
pub(crate) previous_cursor_line: usize,
pub(crate) font_settings_changed: bool,
pub(crate) text_needs_processing: bool,
pub(crate) should_select_current_match: bool,
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
pub(crate) previous_cursor_position: Option<usize>,
}

View File

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

View File

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

View File

@ -1,384 +1,63 @@
use std::sync::Arc;
use std::thread;
use super::editor::{TextEditor, TextProcessingResult};
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;
pub fn start_text_processing_thread(&mut self) {
let _processing_result = Arc::clone(&self.text_processing_result);
let handle = thread::Builder::new()
.name("TextProcessor".to_string())
.spawn(move || {
// Set thread priority to high (platform-specific)
#[cfg(target_os = "linux")]
{
unsafe {
let thread_id = libc::pthread_self();
let mut param: libc::sched_param = std::mem::zeroed();
param.sched_priority = 50;
let _ = libc::pthread_setschedparam(thread_id, libc::SCHED_OTHER, &param);
}
&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;
let lines: Vec<&str> = content.lines().collect();
if content.is_empty() {
self.update_processing_result(TextProcessingResult {
line_count: 1,
longest_line_index: 0,
longest_line_length: 0,
longest_line_pixel_width: 0.0,
});
return;
}
let mut longest_line_index = 0;
let mut longest_line_length = 0;
if lines.is_empty() {
self.update_processing_result(TextProcessingResult {
line_count,
longest_line_index: 0,
longest_line_length: 0,
longest_line_pixel_width: 0.0,
});
return;
}
for (index, line) in lines.iter().enumerate() {
let char_count = line.chars().count();
if char_count > longest_line_length {
longest_line_length = char_count;
longest_line_index = index;
match handle {
Ok(h) => self.processing_thread_handle = Some(h),
Err(e) => eprintln!("Failed to start text processing thread: {}", e),
}
}
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| {
fonts
.layout(
longest_line_text.to_string(),
font_id,
egui::Color32::WHITE,
f32::INFINITY,
)
.size()
.x
})
pub fn process_text_for_rendering(
&mut self,
content: &str,
available_width: f32,
) {
let line_count = content.lines().count().max(1);
let visual_line_mapping = if self.word_wrap {
// For now, simplified mapping - this could be moved to background thread
(1..=line_count).map(Some).collect()
} else {
0.0
(1..=line_count).map(Some).collect()
};
let result = TextProcessingResult {
line_count,
longest_line_index,
longest_line_length,
longest_line_pixel_width,
visual_line_mapping,
max_line_length: available_width,
_processed_content: content.to_string(),
};
self.update_processing_result(result);
}
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;
}
fn calculate_cursor_line_change(
&self,
old_content: &str,
new_content: &str,
old_cursor_pos: usize,
new_cursor_pos: usize,
) -> isize {
let old_newlines = Self::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)
.bytes()
.filter(|&b| b == b'\n')
.count();
new_newlines as isize - old_newlines as isize
}
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,
);
}
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;
let addition_start_line = Self::safe_slice_to_pos(old_content, added_start)
.bytes()
.filter(|&b| b == b'\n')
.count();
let addition_end_line = Self::safe_slice_to_pos(old_content, added_end)
.bytes()
.filter(|&b| b == b'\n')
.count();
if current_result.longest_line_index >= addition_start_line
&& current_result.longest_line_index <= addition_end_line
{
self.process_text_for_rendering(new_content, ui);
} else {
if addition_end_line < current_result.longest_line_index {
current_result.longest_line_index += newlines_added;
}
self.update_processing_result(current_result);
}
} else {
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,
);
}
}
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);
let removal_start_line = Self::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)
.bytes()
.filter(|&b| b == b'\n')
.count();
if current_result.longest_line_index >= removal_start_line
&& current_result.longest_line_index <= removal_end_line
{
self.process_text_for_rendering(new_content, ui);
} else {
if removal_end_line < current_result.longest_line_index {
current_result.longest_line_index = current_result
.longest_line_index
.saturating_sub(newlines_removed);
}
self.update_processing_result(current_result);
}
}
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,
);
}
}
fn extract_current_line(&self, content: &str, cursor_pos: usize) -> String {
let bytes = content.as_bytes();
let safe_cursor_pos = cursor_pos.min(bytes.len());
let mut line_start = safe_cursor_pos;
while line_start > 0 && bytes[line_start - 1] != b'\n' {
line_start -= 1;
}
let mut line_end = safe_cursor_pos;
while line_end < bytes.len() && bytes[line_end] != b'\n' {
line_end += 1;
}
let line_start_boundary = line_start;
let line_end_boundary = line_end;
if content.is_char_boundary(line_start_boundary)
&& content.is_char_boundary(line_end_boundary)
{
content[line_start_boundary..line_end_boundary].to_string()
} else {
Self::safe_slice_to_pos(content, line_end_boundary)[line_start_boundary..].to_string()
}
}
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);
if let Ok(mut processing_result) = self.text_processing_result.lock() {
*processing_result = result;
}
}
pub fn get_text_processing_result(&self) -> TextProcessingResult {
self.text_processing_result
.lock()
.map(|result| result.to_owned())
.map(|result| result.clone())
.unwrap_or_default()
}
fn update_processing_result(&self, result: TextProcessingResult) {
if let Ok(mut processing_result) = self.text_processing_result.lock() {
*processing_result = result;
}
}
}

View File

@ -1,255 +0,0 @@
use super::editor::TextEditor;
use crate::app::tab::{compute_content_hash, Tab};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedTab {
pub diff_file: Option<PathBuf>,
pub full_content: Option<String>, // This is used for 'new files' that don't have a path
pub file_path: Option<PathBuf>,
pub is_modified: bool,
pub title: String,
pub original_content_hash: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateCache {
pub tabs: Vec<CachedTab>,
pub active_tab_index: usize,
pub tab_counter: usize,
}
fn create_diff_file(diff_content: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
let diffs_dir = TextEditor::diffs_cache_dir().ok_or("Cannot determine cache directory")?;
std::fs::create_dir_all(&diffs_dir)?;
let diff_filename = format!("{}.diff", Uuid::new_v4());
let diff_path = diffs_dir.join(diff_filename);
std::fs::write(&diff_path, diff_content)?;
Ok(diff_path)
}
fn load_diff_file(diff_path: &PathBuf) -> Result<String, Box<dyn std::error::Error>> {
Ok(std::fs::read_to_string(diff_path)?)
}
impl From<&Tab> for CachedTab {
fn from(tab: &Tab) -> Self {
if let Some(file_path) = &tab.file_path {
let original_content = std::fs::read_to_string(file_path).unwrap_or_default();
let diff_file = if tab.is_modified {
let diff_content = diffy::create_patch(&original_content, &tab.content);
match create_diff_file(&diff_content.to_string()) {
Ok(path) => Some(path),
Err(e) => {
eprintln!("Warning: Failed to create diff file: {}", e);
None
}
}
} else {
None
};
Self {
diff_file,
full_content: None,
file_path: tab.file_path.clone(),
is_modified: tab.is_modified,
title: tab.title.clone(),
original_content_hash: tab.original_content_hash,
}
} else {
Self {
diff_file: None,
full_content: Some(tab.content.clone()),
file_path: None,
is_modified: tab.is_modified,
title: tab.title.clone(),
original_content_hash: tab.original_content_hash,
}
}
}
}
impl From<CachedTab> for Tab {
fn from(cached: CachedTab) -> Self {
if let Some(file_path) = cached.file_path {
let original_content = std::fs::read_to_string(&file_path).unwrap_or_default();
let current_content = if let Some(diff_path) = cached.diff_file {
match load_diff_file(&diff_path) {
Ok(diff_content) => {
match diffy::Patch::from_str(&diff_content) {
Ok(patch) => match diffy::apply(&original_content, &patch) {
Ok(content) => content,
Err(_) => {
eprintln!("Warning: Failed to apply diff for {}, using original content",
file_path.display());
original_content
}
},
Err(_) => {
eprintln!(
"Warning: Failed to parse diff for {}, using original content",
file_path.display()
);
original_content
}
}
}
Err(e) => {
eprintln!(
"Warning: Failed to load diff file {:?}: {}, using original content",
diff_path, e
);
original_content
}
}
} else {
original_content
};
let original_hash =
compute_content_hash(&std::fs::read_to_string(&file_path).unwrap_or_default());
let expected_hash = cached.original_content_hash;
let mut tab = Tab::new_with_file(current_content, file_path);
tab.title = cached.title;
if original_hash != expected_hash {
tab.is_modified = true;
} else {
tab.is_modified = cached.is_modified;
tab.original_content_hash = cached.original_content_hash;
}
tab
} else {
let content = cached.full_content.unwrap_or_default();
let mut tab = Tab::new_empty(1);
tab.content = content;
tab.title = cached.title;
tab.is_modified = cached.is_modified;
tab.original_content_hash = cached.original_content_hash;
tab
}
}
}
impl TextEditor {
pub fn state_cache_path() -> Option<PathBuf> {
let cache_dir = if let Some(cache_dir) = dirs::cache_dir() {
cache_dir.join(env!("CARGO_PKG_NAME"))
} else if let Some(home_dir) = dirs::home_dir() {
home_dir.join(".cache").join(env!("CARGO_PKG_NAME"))
} else {
return None;
};
Some(cache_dir.join("state.json"))
}
pub fn diffs_cache_dir() -> Option<PathBuf> {
let cache_dir = if let Some(cache_dir) = dirs::cache_dir() {
cache_dir.join(env!("CARGO_PKG_NAME"))
} else if let Some(home_dir) = dirs::home_dir() {
home_dir.join(".cache").join(env!("CARGO_PKG_NAME"))
} else {
return None;
};
Some(cache_dir.join("diffs"))
}
fn cleanup_orphaned_diffs(
active_diff_files: &[PathBuf],
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(diffs_dir) = Self::diffs_cache_dir() {
if diffs_dir.exists() {
for entry in std::fs::read_dir(diffs_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("diff") {
if !active_diff_files.contains(&path) {
let _ = std::fs::remove_file(path);
}
}
}
}
}
Ok(())
}
pub fn load_state_cache(&mut self) -> Result<(), Box<dyn std::error::Error>> {
if !self.state_cache {
return Ok(());
}
let cache_path = Self::state_cache_path().ok_or("Cannot determine cache directory")?;
if !cache_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&cache_path)?;
let state_cache: StateCache = serde_json::from_str(&content)?;
if !state_cache.tabs.is_empty() {
self.tabs = state_cache.tabs.into_iter().map(Tab::from).collect();
self.active_tab_index =
std::cmp::min(state_cache.active_tab_index, self.tabs.len() - 1);
self.tab_counter = state_cache.tab_counter;
self.text_needs_processing = true;
}
Ok(())
}
pub fn save_state_cache(&self) -> Result<(), Box<dyn std::error::Error>> {
if !self.state_cache {
return Ok(());
}
let cache_path = Self::state_cache_path().ok_or("Cannot determine cache directory")?;
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent)?;
}
let state_cache = StateCache {
tabs: self.tabs.iter().map(CachedTab::from).collect(),
active_tab_index: self.active_tab_index,
tab_counter: self.tab_counter,
};
let active_diff_files: Vec<PathBuf> = state_cache
.tabs
.iter()
.filter_map(|tab| tab.diff_file.clone())
.collect();
let _ = Self::cleanup_orphaned_diffs(&active_diff_files);
let content = serde_json::to_string_pretty(&state_cache)?;
std::fs::write(&cache_path, content)?;
Ok(())
}
pub fn clear_state_cache() -> Result<(), Box<dyn std::error::Error>> {
if let Some(cache_path) = Self::state_cache_path() {
if cache_path.exists() {
std::fs::remove_file(cache_path)?;
}
}
if let Some(diffs_dir) = Self::diffs_cache_dir() {
if diffs_dir.exists() {
let _ = std::fs::remove_dir_all(diffs_dir);
}
}
Ok(())
}
}

View File

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

View File

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

View File

@ -2,9 +2,8 @@ use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
pub fn compute_content_hash(content: &str) -> u64 {
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
pub fn compute_content_hash(content: &str, hasher: &mut DefaultHasher) -> u64 {
content.hash(hasher);
hasher.finish()
}
@ -12,21 +11,26 @@ pub fn compute_content_hash(content: &str) -> u64 {
pub struct Tab {
pub content: String,
pub original_content_hash: u64,
pub last_content_hash: u64,
pub file_path: Option<PathBuf>,
pub is_modified: bool,
pub title: String,
hasher: DefaultHasher,
}
impl Tab {
pub fn new_empty(tab_number: usize) -> Self {
let content = String::new();
let hash = compute_content_hash(&content);
let mut hasher = DefaultHasher::new();
let hash = compute_content_hash(&content, &mut hasher);
Self {
original_content_hash: hash,
last_content_hash: hash,
content,
file_path: None,
is_modified: false,
title: format!("new_{tab_number}"),
title: format!("new_{}", tab_number),
hasher,
}
}
@ -34,16 +38,19 @@ impl Tab {
let title = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("UNKNOWN")
.unwrap_or("Untitled")
.to_string();
let hash = compute_content_hash(&content);
let mut hasher = DefaultHasher::new();
let hash = compute_content_hash(&content, &mut hasher);
Self {
original_content_hash: hash,
last_content_hash: hash,
content,
file_path: Some(file_path),
is_modified: false,
title,
hasher,
}
}
@ -53,16 +60,21 @@ impl Tab {
}
pub fn update_modified_state(&mut self) {
// Compare current content hash with original content hash to determine if modified
// Special case: new_X tabs are only considered modified if they have content
if self.title.starts_with("new_") {
self.is_modified = !self.content.is_empty();
} else {
let current_hash = compute_content_hash(&self.content);
self.is_modified = current_hash != self.original_content_hash;
let current_hash = compute_content_hash(&self.content, &mut self.hasher);
self.is_modified = current_hash != self.last_content_hash;
self.last_content_hash = current_hash;
}
}
pub fn mark_as_saved(&mut self) {
self.original_content_hash = compute_content_hash(&self.content);
// Update the original content hash to match current content after saving
self.original_content_hash = compute_content_hash(&self.content, &mut self.hasher);
self.last_content_hash = self.original_content_hash;
self.is_modified = false;
}
}

View File

@ -1,9 +1,4 @@
use eframe::egui;
use plist::{Dictionary, Value};
use std::collections::BTreeMap;
use syntect::highlighting::{
Color as SyntectColor, Theme as SyntectTheme, ThemeSet, ThemeSettings, UnderlineOption,
};
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize, Default)]
pub enum Theme {
@ -82,7 +77,7 @@ fn get_pywal_colors() -> Option<egui::Visuals> {
let fg = parse_color(colors.get(7).unwrap_or(&colors[0]))?;
let bg_alt = parse_color(colors.get(8).unwrap_or(&colors[0]))?;
let accent = parse_color(colors.get(1).unwrap_or(&colors[0]))?;
let _secondary = parse_color(colors.get(2).unwrap_or(&colors[0]))?;
let secondary = parse_color(colors.get(2).unwrap_or(&colors[0]))?;
let mut visuals = if is_dark_color(bg) {
egui::Visuals::dark()
@ -199,127 +194,3 @@ fn detect_system_dark_mode() -> bool {
true
}
}
fn egui_color_to_syntect(color: egui::Color32) -> SyntectColor {
SyntectColor {
r: color.r(),
g: color.g(),
b: color.b(),
a: color.a(),
}
}
pub fn create_code_theme_from_visuals(visuals: &egui::Visuals, font_size: f32) -> ThemeSet {
let text_color = visuals.override_text_color.unwrap_or(visuals.text_color());
let bg_color = visuals.extreme_bg_color;
let selection_color = visuals.selection.bg_fill;
let comment_color = blend_colors(text_color, bg_color, 0.6);
let keyword_color = if visuals.dark_mode {
blend_colors(egui::Color32::from_rgb(100, 149, 237), text_color, 0.8) // CornflowerBlue-like
} else {
blend_colors(egui::Color32::from_rgb(0, 0, 139), text_color, 0.8) // DarkBlue-like
};
let string_color = if visuals.dark_mode {
blend_colors(egui::Color32::from_rgb(144, 238, 144), text_color, 0.8) // LightGreen-like
} else {
blend_colors(egui::Color32::from_rgb(0, 128, 0), text_color, 0.8) // Green-like
};
let number_color = if visuals.dark_mode {
blend_colors(egui::Color32::from_rgb(255, 165, 0), text_color, 0.8) // Orange-like
} else {
blend_colors(egui::Color32::from_rgb(165, 42, 42), text_color, 0.8) // Brown-like
};
let function_color = if visuals.dark_mode {
blend_colors(egui::Color32::from_rgb(255, 20, 147), text_color, 0.8) // DeepPink-like
} else {
blend_colors(egui::Color32::from_rgb(128, 0, 128), text_color, 0.8) // Purple-like
};
let plist_theme = build_custom_theme_plist(
"System",
&format!("{:?}", bg_color),
&format!("{:?}", text_color),
&format!("{:?}", comment_color),
&format!("{:?}", string_color),
&format!("{:?}", keyword_color),
);
let file = std::fs::File::create("system.tmTheme").unwrap();
let writer = std::io::BufWriter::new(file);
let _ = plist::to_writer_xml(writer, &plist_theme);
let loaded_file = std::fs::File::open("system.tmTheme").unwrap();
let mut loaded_reader = std::io::BufReader::new(loaded_file);
let loaded_theme = ThemeSet::load_from_reader(&mut loaded_reader).unwrap();
let mut set = ThemeSet::new();
set.add_from_folder(".").unwrap();
return set;
}
fn build_custom_theme_plist(
theme_name: &str,
background_color: &str,
foreground_color: &str,
comment_color: &str,
string_color: &str,
keyword_color: &str,
) -> Value {
let mut root_dict = Dictionary::new();
root_dict.insert("name".to_string(), Value::String(theme_name.to_string()));
let mut settings_array = Vec::new();
let mut global_settings_dict = Dictionary::new();
let mut inner_global_settings = Dictionary::new();
inner_global_settings.insert(
"background".to_string(),
Value::String(background_color.to_string()),
);
inner_global_settings.insert(
"foreground".to_string(),
Value::String(foreground_color.to_string()),
);
global_settings_dict.insert(
"settings".to_string(),
Value::Dictionary(inner_global_settings),
);
settings_array.push(Value::Dictionary(global_settings_dict));
let mut comment_scope_dict = Dictionary::new();
comment_scope_dict.insert("name".to_string(), Value::String("Comment".to_string()));
comment_scope_dict.insert("scope".to_string(), Value::String("comment".to_string()));
let mut comment_settings = Dictionary::new();
comment_settings.insert(
"foreground".to_string(),
Value::String(comment_color.to_string()),
);
comment_settings.insert("fontStyle".to_string(), Value::String("italic".to_string()));
comment_scope_dict.insert("settings".to_string(), Value::Dictionary(comment_settings));
settings_array.push(Value::Dictionary(comment_scope_dict));
let mut string_scope_dict = Dictionary::new();
string_scope_dict.insert("name".to_string(), Value::String("String".to_string()));
string_scope_dict.insert("scope".to_string(), Value::String("string".to_string()));
let mut string_settings = Dictionary::new();
string_settings.insert(
"foreground".to_string(),
Value::String(string_color.to_string()),
);
string_scope_dict.insert("settings".to_string(), Value::Dictionary(string_settings));
settings_array.push(Value::Dictionary(string_scope_dict));
let mut keyword_scope_dict = Dictionary::new();
keyword_scope_dict.insert("name".to_string(), Value::String("Keyword".to_string()));
keyword_scope_dict.insert("scope".to_string(), Value::String("keyword".to_string()));
let mut keyword_settings = Dictionary::new();
keyword_settings.insert(
"foreground".to_string(),
Value::String(keyword_color.to_string()),
);
keyword_scope_dict.insert("settings".to_string(), Value::Dictionary(keyword_settings));
settings_array.push(Value::Dictionary(keyword_scope_dict));
root_dict.insert("settings".to_string(), Value::Array(settings_array));
Value::Dictionary(root_dict)
}

196
src/io.rs
View File

@ -7,116 +7,6 @@ pub(crate) fn new_file(app: &mut TextEditor) {
app.add_new_tab();
}
fn is_text_file(path: &PathBuf) -> bool {
if let Some(extension) = path.extension().and_then(|s| s.to_str()) {
matches!(
extension.to_lowercase().as_str(),
"txt"
| "md"
| "markdown"
| "rs"
| "py"
| "js"
| "ts"
| "tsx"
| "jsx"
| "c"
| "cpp"
| "cc"
| "cxx"
| "h"
| "hpp"
| "java"
| "go"
| "php"
| "rb"
| "cs"
| "swift"
| "kt"
| "scala"
| "sh"
| "bash"
| "zsh"
| "fish"
| "html"
| "htm"
| "xml"
| "css"
| "scss"
| "sass"
| "json"
| "yaml"
| "yml"
| "toml"
| "sql"
| "lua"
| "vim"
| "dockerfile"
| "makefile"
| "gitignore"
| "conf"
| "cfg"
| "ini"
| "log"
| "csv"
| "tsv"
)
} else {
// Files without extensions might be text files, but let's be conservative
// and only include them if they're small and readable
if let Ok(metadata) = fs::metadata(path) {
metadata.len() < 1024 * 1024 // Only consider files smaller than 1MB
} else {
false
}
}
}
pub(crate) fn open_files_from_directory(
app: &mut TextEditor,
dir_path: PathBuf,
) -> Result<usize, String> {
if !dir_path.is_dir() {
return Err(format!("{} is not a directory", dir_path.display()));
}
let entries = fs::read_dir(&dir_path)
.map_err(|e| format!("Failed to read directory {}: {}", dir_path.display(), e))?;
let mut opened_count = 0;
let mut text_files: Vec<PathBuf> = Vec::new();
// Collect all text files in the directory
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
let path = entry.path();
if path.is_file() && is_text_file(&path) {
text_files.push(path);
}
}
// Sort files by name for consistent ordering
text_files.sort();
// Open each text file
for file_path in text_files {
match open_file_from_path(app, file_path.clone()) {
Ok(()) => opened_count += 1,
Err(e) => eprintln!("Warning: {}", e),
}
}
if opened_count == 0 {
Err(format!(
"No text files found in directory {}",
dir_path.display()
))
} else {
Ok(opened_count)
}
}
pub(crate) fn open_file(app: &mut TextEditor) {
if let Some(path) = rfd::FileDialog::new()
.add_filter("Text files", &["*"])
@ -124,6 +14,7 @@ pub(crate) fn open_file(app: &mut TextEditor) {
{
match fs::read_to_string(&path) {
Ok(content) => {
// Check if the current active tab is empty/clean and can be replaced
let should_replace_current_tab = if let Some(active_tab) = app.get_active_tab() {
active_tab.file_path.is_none()
&& active_tab.content.is_empty()
@ -133,86 +24,35 @@ pub(crate) fn open_file(app: &mut TextEditor) {
};
if should_replace_current_tab {
// Replace the current empty tab
if let Some(active_tab) = app.get_active_tab_mut() {
let title = path
active_tab.content = content;
active_tab.file_path = Some(path.clone());
active_tab.title = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Untitled");
active_tab.content = content;
active_tab.file_path = Some(path.to_path_buf());
active_tab.title = title.to_string();
active_tab.mark_as_saved();
.unwrap_or("Untitled")
.to_string();
active_tab.mark_as_saved(); // This will set the hash and mark as not modified
}
app.text_needs_processing = true;
} else {
// Create a new tab as before
let new_tab = Tab::new_with_file(content, path);
app.tabs.push(new_tab);
app.active_tab_index = app.tabs.len() - 1;
app.text_needs_processing = true;
}
if app.show_find && !app.find_query.is_empty() {
app.update_find_matches();
}
if let Err(e) = app.save_state_cache() {
eprintln!("Failed to save state cache: {e}");
}
}
Err(err) => {
eprintln!("Failed to open file: {err}");
eprintln!("Failed to open file: {}", err);
}
}
}
}
pub(crate) fn open_file_from_path(app: &mut TextEditor, path: PathBuf) -> Result<(), String> {
match fs::read_to_string(&path) {
Ok(content) => {
let should_replace_current_tab = if let Some(active_tab) = app.get_active_tab() {
active_tab.file_path.is_none()
&& active_tab.content.is_empty()
&& !active_tab.is_modified
} else {
false
};
if should_replace_current_tab {
if let Some(active_tab) = app.get_active_tab_mut() {
let title = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Untitled");
active_tab.content = content;
active_tab.file_path = Some(path.to_path_buf());
active_tab.title = title.to_string();
active_tab.mark_as_saved();
}
app.text_needs_processing = true;
} else {
let new_tab = Tab::new_with_file(content, path);
app.tabs.push(new_tab);
app.active_tab_index = app.tabs.len() - 1;
app.text_needs_processing = true;
}
if app.show_find && !app.find_query.is_empty() {
app.update_find_matches();
}
if let Err(e) = app.save_state_cache() {
eprintln!("Failed to save state cache: {e}");
}
Ok(())
}
Err(err) => Err(format!("Failed to open file {}: {}", path.display(), err)),
}
}
pub(crate) fn save_file(app: &mut TextEditor) {
if let Some(active_tab) = app.get_active_tab() {
if let Some(path) = &active_tab.file_path {
save_to_path(app, path.to_path_buf());
save_to_path(app, path.clone());
} else {
save_as_file(app);
}
@ -232,20 +72,16 @@ pub(crate) fn save_to_path(app: &mut TextEditor, path: PathBuf) {
if let Some(active_tab) = app.get_active_tab_mut() {
match fs::write(&path, &active_tab.content) {
Ok(()) => {
let title = path
active_tab.file_path = Some(path.clone());
active_tab.title = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Untitled");
active_tab.file_path = Some(path.to_path_buf());
active_tab.title = title.to_string();
.unwrap_or("Untitled")
.to_string();
active_tab.mark_as_saved();
if let Err(e) = app.save_state_cache() {
eprintln!("Failed to save state cache: {e}");
}
}
Err(err) => {
eprintln!("Failed to save file: {err}");
eprintln!("Failed to save file: {}", err);
}
}
}

View File

@ -1,43 +1,26 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use eframe::egui;
use std::env;
use std::io::IsTerminal;
use std::path::PathBuf;
mod app;
mod io;
mod ui;
use app::{config::Config, TextEditor};
use app::{TextEditor, config::Config};
fn main() -> eframe::Result {
let args: Vec<String> = env::args().collect();
let initial_paths: Vec<PathBuf> = args.iter().skip(1).map(|arg| PathBuf::from(arg)).collect();
if std::io::stdin().is_terminal() {
println!("This is a GUI application, are you sure you want to launch from terminal?");
}
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_min_inner_size([600.0, 400.0])
.with_title("ced")
.with_app_id("io.lampnet.ced"),
.with_title("C-Ext")
.with_app_id("io.lampnet.c-ext"),
..Default::default()
};
let config = Config::load();
eframe::run_native(
"ced",
"C-Ext",
options,
Box::new(move |cc| {
Ok(Box::new(TextEditor::from_config_with_context(
config,
cc,
initial_paths,
)))
}),
Box::new(move |cc| Ok(Box::new(TextEditor::from_config_with_context(config, cc)))),
)
}

View File

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

View File

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

View File

@ -1,12 +1,9 @@
mod editor;
mod find_highlight;
mod languages;
mod line_numbers;
use crate::app::TextEditor;
use crate::ui::constants::*;
use eframe::egui;
use egui::UiKind;
use self::editor::editor_view_ui;
use self::line_numbers::{get_visual_line_mapping, render_line_numbers};
@ -17,7 +14,7 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
let line_side = app.line_side;
let font_size = app.font_size;
let _output = egui::CentralPanel::default()
egui::CentralPanel::default()
.frame(egui::Frame::NONE)
.show(ctx, |ui| {
let bg_color = ui.visuals().extreme_bg_color;
@ -26,20 +23,11 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
let editor_height = panel_rect.height();
if !show_line_numbers || app.get_active_tab().is_none() {
let _scroll_response =
egui::ScrollArea::vertical()
.auto_shrink([false; 2])
.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);
});
handle_empty(ui, app, &context_response);
});
return;
}
@ -75,146 +63,50 @@ pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
};
let separator_widget = |ui: &mut egui::Ui| {
ui.add_space(SMALL);
ui.add_space(3.0);
let separator_x = ui.cursor().left();
let mut y_range = ui.available_rect_before_wrap().y_range();
y_range.max += 2.0 * font_size;
y_range.max += 2.0 * font_size; // Extend separator to cover more vertical space
ui.painter()
.vline(separator_x, y_range, ui.visuals().window_stroke);
ui.add_space(SMALL);
ui.add_space(4.0);
};
egui::ScrollArea::vertical()
.auto_shrink([false; 2])
.show(ui, |ui| {
if line_side {
let text_editor_width =
editor_dimensions.text_width + editor_dimensions.total_reserved_width;
// Line numbers on the right
let text_editor_width = editor_dimensions.text_width + editor_dimensions.total_reserved_width;
ui.allocate_ui_with_layout(
egui::vec2(text_editor_width, editor_height),
egui::Layout::left_to_right(egui::Align::TOP),
|ui| {
// Constrain editor to specific width to leave space for line numbers
ui.allocate_ui_with_layout(
egui::vec2(editor_dimensions.text_width, editor_height),
egui::Layout::left_to_right(egui::Align::TOP),
|ui| {
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);
},
);
handle_empty(ui, app, &context_response);
},
);
separator_widget(ui);
line_numbers_widget(ui);
},
);
} else {
let text_editor_width =
editor_dimensions.text_width + editor_dimensions.total_reserved_width;
// Line numbers on the left
let text_editor_width = editor_dimensions.text_width + editor_dimensions.total_reserved_width;
ui.allocate_ui_with_layout(
egui::vec2(text_editor_width, editor_height),
egui::Layout::left_to_right(egui::Align::TOP),
|ui| {
line_numbers_widget(ui);
separator_widget(ui);
let editor_area = ui.available_rect_before_wrap();
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);
},
);
handle_empty(ui, app, &context_response);
},
);
}
});
});
}
fn handle_empty(_ui: &mut egui::Ui, app: &mut TextEditor, context_response: &egui::Response) {
if context_response.clicked() {
let text_edit_id = egui::Id::new("main_text_editor");
if let Some(mut state) = egui::TextEdit::load_state(_ui.ctx(), text_edit_id) {
if let Some(active_tab) = app.get_active_tab() {
let text_len = active_tab.content.len();
let cursor_pos = egui::text::CCursor::new(text_len);
state
.cursor
.set_char_range(Some(egui::text::CCursorRange::one(cursor_pos)));
egui::TextEdit::store_state(_ui.ctx(), text_edit_id, state);
_ui.ctx().memory_mut(|mem| {
mem.request_focus(text_edit_id);
});
}
}
}
context_response.context_menu(|ui| {
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_kind(UiKind::Menu);
}
if ui.button("Copy").clicked() {
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::RequestCopy);
ui.close_kind(UiKind::Menu);
}
if ui.button("Paste").clicked() {
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::RequestPaste);
ui.close_kind(UiKind::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_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) {
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("Reset Zoom").clicked() {
ui.ctx().memory_mut(|mem| {
mem.data.insert_temp(reset_zoom_key, true);
});
ui.close_kind(UiKind::Menu);
}
});
}

View File

@ -1,26 +1,28 @@
use crate::app::TextEditor;
use eframe::egui;
use egui_extras::syntax_highlighting::{self};
use super::find_highlight;
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 _prev_show_find = app.prev_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;
// Check if reset zoom was requested in previous frame
let reset_zoom_key = egui::Id::new("editor_reset_zoom");
let should_reset_zoom = ui
.ctx()
.memory_mut(|mem| mem.data.get_temp::<bool>(reset_zoom_key).unwrap_or(false));
let should_reset_zoom = ui.ctx().memory_mut(|mem| {
mem.data.get_temp::<bool>(reset_zoom_key).unwrap_or(false)
});
// Reset zoom if requested
if should_reset_zoom {
app.zoom_factor = 1.0;
ui.ctx().set_zoom_factor(1.0);
@ -29,204 +31,93 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R
});
}
let estimated_width = if !word_wrap {
app.calculate_content_based_width(ui)
} else {
0.0
};
let find_data = if show_find && !app.find_matches.is_empty() {
app.get_active_tab().map(|tab| {
(
tab.content.to_owned(),
app.find_matches.to_owned(),
app.current_match_index,
)
})
} else {
None
};
let Some(active_tab) = app.get_active_tab_mut() else {
return ui.label("No file open, how did you get here?");
};
if let Some(active_tab) = app.get_active_tab_mut() {
let bg_color = ui.visuals().extreme_bg_color;
let editor_rect = ui.available_rect_before_wrap();
ui.painter().rect_filled(editor_rect, 0.0, bg_color);
if let Some((content, matches, current_match_index)) = &find_data {
let font_id = ui
.style()
.text_styles
.get(&egui::TextStyle::Monospace)
.unwrap_or(&egui::FontId::monospace(font_size))
.to_owned();
let desired_width = if word_wrap {
ui.available_width()
} else {
f32::INFINITY
};
let temp_galley = ui.fonts(|fonts| {
fonts.layout(
content.to_owned(),
font_id.to_owned(),
ui.visuals().text_color(),
desired_width,
)
});
let text_area_left = editor_rect.left() + 4.0;
let text_area_top = editor_rect.top() + 2.0;
find_highlight::draw_find_highlights(
ui,
content,
matches,
*current_match_index,
&temp_galley,
text_area_left,
text_area_top,
font_size,
);
}
let desired_width = if word_wrap {
ui.available_width()
} else {
f32::INFINITY
};
let language = super::languages::get_language_from_extension(active_tab.file_path.as_deref());
let mut layouter = |ui: &egui::Ui, string: &dyn egui::TextBuffer, wrap_width: f32| {
// let syntect_theme =
// crate::app::theme::create_code_theme_from_visuals(ui.visuals(), font_size);
let theme = egui_extras::syntax_highlighting::CodeTheme::from_style(ui.style());
let text = string.as_str();
let mut layout_job = if syntax_highlighting_enabled && language != "txt" {
// let mut settings = egui_extras::syntax_highlighting::SyntectSettings::default();
// settings.ts = syntect_theme;
// syntax_highlighting::highlight_with(ui.ctx(), &ui.style().clone(), &theme, text, &language, &settings)
syntax_highlighting::highlight(ui.ctx(), &ui.style().clone(), &theme, text, &language)
} else {
syntax_highlighting::highlight(ui.ctx(), &ui.style().clone(), &theme, text, "")
};
if syntax_highlighting_enabled && language != "txt" {
for section in &mut layout_job.sections {
section.format.font_id = font_id.clone();
}
}
layout_job.wrap.max_width = wrap_width;
ui.fonts(|f| f.layout_job(layout_job))
};
let text_edit = egui::TextEdit::multiline(&mut active_tab.content)
.frame(false)
.font(egui::TextStyle::Monospace)
.code_editor()
.desired_width(desired_width)
.desired_rows(0)
.lock_focus(!show_find)
.lock_focus(true)
.cursor_at_end(false)
.layouter(&mut layouter)
.id(egui::Id::new("main_text_editor"));
let output = if word_wrap {
text_edit.show(ui)
} else {
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| text_edit.show(ui),
)
let output = text_edit.show(ui);
// Store text length for context menu
let text_len = active_tab.content.len();
// Right-click context menu
output.response.context_menu(|ui| {
if ui.button("Cut").clicked() {
ui.ctx().input_mut(|i| i.events.push(egui::Event::Cut));
ui.close_menu();
}
if ui.button("Copy").clicked() {
ui.ctx().input_mut(|i| i.events.push(egui::Event::Copy));
ui.close_menu();
}
if ui.button("Paste").clicked() {
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::RequestPaste);
ui.close_menu();
}
if ui.button("Delete").clicked() {
ui.ctx().input_mut(|i| {
i.events.push(egui::Event::Key {
key: egui::Key::Delete,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
})
.inner
.inner
};
let content_changed = output.response.changed();
let content_for_processing = if content_changed {
active_tab.update_modified_state();
Some(active_tab.content.to_owned())
} else {
None
};
if content_changed {
if let Err(e) = app.save_state_cache() {
eprintln!("Failed to save state cache: {e}");
});
ui.close_menu();
}
}
if content_changed && app.show_find && !app.find_query.is_empty() {
app.update_find_matches();
}
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.to_owned();
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,
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);
}
} else {
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();
}
});
app.previous_content = content.to_owned();
app.previous_cursor_char_index = current_cursor_pos;
}
if app.font_settings_changed || app.text_needs_processing {
if let Some(active_tab) = app.get_active_tab() {
let content = active_tab.content.to_owned();
app.process_text_for_rendering(&content, ui);
}
app.font_settings_changed = false;
app.text_needs_processing = 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 cursor_rect = if let Some(cursor_range) = output.state.cursor.char_range() {
let cursor_pos = cursor_range.primary.index;
let content = &active_tab.content;
let cursor_line = content
.char_indices()
.take_while(|(byte_pos, _)| *byte_pos < cursor_pos)
.filter(|(_, ch)| *ch == '\n')
.count();
let text_up_to_cursor = &content[..cursor_pos.min(content.len())];
let cursor_line = text_up_to_cursor.chars().filter(|&c| c == '\n').count();
let font_id = ui
.style()
.text_styles
.get(&egui::TextStyle::Monospace)
.unwrap_or(&egui::FontId::monospace(font_size))
.to_owned();
.clone();
let line_height = ui.fonts(|fonts| fonts.row_height(&font_id));
let y_pos = output.response.rect.top() + (cursor_line as f32 * line_height);
@ -234,17 +125,44 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R
egui::pos2(output.response.rect.left(), y_pos),
egui::vec2(2.0, line_height),
);
Some(cursor_rect)
} else {
None
};
let visible_area = ui.clip_rect();
if !visible_area.intersects(cursor_rect) {
ui.scroll_to_rect(cursor_rect, Some(egui::Align::Center));
if !show_find && prev_show_find {
if let Some((start_pos, end_pos)) = current_match_position {
let text_edit_id = egui::Id::new("main_text_editor");
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) {
let cursor_range = egui::text::CCursorRange::two(
egui::text::CCursor::new(start_pos),
egui::text::CCursor::new(end_pos),
);
state.cursor.set_char_range(Some(cursor_range));
egui::TextEdit::store_state(ui.ctx(), text_edit_id, state);
}
}
}
app.previous_cursor_position = Some(cursor_pos);
if show_find {
if let Some((start_pos, end_pos)) = current_match_position {
draw_find_highlight(
ui,
&active_tab.content,
start_pos,
end_pos,
output.response.rect,
font_size,
);
}
}
if output.response.changed() {
active_tab.update_modified_state();
app.find_matches.clear();
app.current_match_index = None;
}
if !output.response.has_focus()
&& !show_preferences
&& !show_about
@ -253,6 +171,52 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R
{
output.response.request_focus();
}
output.response
(output.response, cursor_rect)
} else {
(ui.label("No file open, how did you get here?"), None)
}
}
pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) {
let word_wrap = app.word_wrap;
if word_wrap {
let (_response, _cursor_rect) = editor_view(ui, app);
} else {
let estimated_width = app.calculate_content_based_width(ui);
let output = egui::ScrollArea::horizontal()
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.allocate_ui_with_layout(
egui::Vec2::new(estimated_width, ui.available_height()),
egui::Layout::left_to_right(egui::Align::TOP),
|ui| editor_view(ui, app),
)
});
let editor_response = &output.inner.inner.0;
if let Some(cursor_rect) = output.inner.inner.1 {
let text_edit_id = egui::Id::new("main_text_editor");
let current_cursor_pos =
if let Some(state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) {
state.cursor.char_range().map(|range| range.primary.index)
} else {
None
};
let cursor_moved = current_cursor_pos != app.previous_cursor_position;
let text_changed = editor_response.changed();
let should_scroll = (cursor_moved || text_changed)
&& {
let visible_area = ui.clip_rect();
!visible_area.intersects(cursor_rect)
};
if should_scroll {
ui.scroll_to_rect(cursor_rect, Some(egui::Align::Center));
}
app.previous_cursor_position = current_cursor_pos;
}
}
}

View File

@ -1,23 +1,11 @@
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(
pub(super) fn draw_find_highlight(
ui: &mut egui::Ui,
content: &str,
matches: &[(usize, usize)],
current_match_index: Option<usize>,
galley: &std::sync::Arc<egui::Galley>,
text_area_left: f32,
text_area_top: f32,
start_pos: usize,
end_pos: usize,
editor_rect: egui::Rect,
font_size: f32,
) {
let font_id = ui
@ -25,47 +13,15 @@ pub(super) fn draw_find_highlights(
.text_styles
.get(&egui::TextStyle::Monospace)
.unwrap_or(&egui::FontId::monospace(font_size))
.to_owned();
.clone();
for (match_index, &(start_pos, end_pos)) in matches.iter().enumerate() {
let is_current_match = current_match_index == Some(match_index);
draw_single_highlight(
ui,
content,
start_pos,
end_pos,
text_area_left,
text_area_top,
galley,
&font_id,
is_current_match,
);
}
}
let text_up_to_start = &content[..start_pos.min(content.len())];
fn draw_single_highlight(
ui: &mut egui::Ui,
content: &str,
start_pos: usize,
end_pos: usize,
text_area_left: f32,
text_area_top: f32,
galley: &std::sync::Arc<egui::Galley>,
font_id: &egui::FontId,
is_current_match: bool,
) {
let text_up_to_start = safe_slice_to_pos(content, start_pos);
let start_line = text_up_to_start.chars().filter(|&c| c == '\n').count();
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 line_start_char_pos = content[..line_start_byte_pos].chars().count();
let start_char_pos = content[..start_pos].chars().count();
let start_col = start_char_pos - line_start_char_pos;
let lines: Vec<&str> = content.lines().collect();
@ -76,11 +32,18 @@ fn draw_single_highlight(
let line_text = lines[start_line];
let text_before_match: String = line_text.chars().take(start_col).collect();
let line_height = ui.fonts(|fonts| fonts.row_height(&font_id));
let horizontal_margin = ui.spacing().button_padding.x - 4.0;
let vertical_margin = ui.spacing().button_padding.y - 1.0;
let text_area_left = editor_rect.left() + horizontal_margin;
let text_area_top = editor_rect.top() + vertical_margin;
let text_before_width = ui.fonts(|fonts| {
fonts
.layout(
text_before_match,
font_id.to_owned(),
font_id.clone(),
egui::Color32::WHITE,
f32::INFINITY,
)
@ -88,17 +51,17 @@ fn draw_single_highlight(
.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_y = text_area_top + (start_line as f32 * line_height);
let start_x = text_area_left + text_before_width;
{
let match_text = &content[start_pos..end_pos.min(content.len())];
let match_width = ui.fonts(|fonts| {
fonts
.layout(
match_text.to_string(),
font_id.to_owned(),
font_id.clone(),
ui.visuals().text_color(),
f32::INFINITY,
)
@ -111,12 +74,10 @@ fn draw_single_highlight(
egui::vec2(match_width, line_height),
);
let highlight_color = if is_current_match {
ui.visuals().selection.bg_fill
} else {
ui.visuals().selection.bg_fill.gamma_multiply(0.6)
};
let painter = ui.painter();
painter.rect_filled(highlight_rect, 0.0, highlight_color);
ui.painter().rect_filled(
highlight_rect,
0.0,
ui.visuals().selection.bg_fill,
);
}
}

View File

@ -1,55 +0,0 @@
pub fn get_language_from_extension(file_path: Option<&std::path::Path>) -> String {
let default_lang = "txt".to_string();
let path = match file_path {
Some(p) => p,
None => return default_lang,
};
if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
match extension.to_lowercase().as_str() {
"rs" => "rs".to_string(),
"py" => "py".to_string(),
"js" => "js".to_string(),
"ts" => "ts".to_string(),
"tsx" => "tsx".to_string(),
"jsx" => "jsx".to_string(),
"c" => "c".to_string(),
"cpp" | "cc" | "cxx" => "cpp".to_string(),
"h" | "hpp" => "cpp".to_string(),
"java" => "java".to_string(),
"go" => "go".to_string(),
"php" => "php".to_string(),
"rb" => "rb".to_string(),
"cs" => "cs".to_string(),
"swift" => "swift".to_string(),
"kt" => "kt".to_string(),
"scala" => "scala".to_string(),
"sh" | "bash" | "zsh" | "fish" => "sh".to_string(),
"html" | "htm" => "html".to_string(),
"xml" => "xml".to_string(),
"css" => "css".to_string(),
"scss" | "sass" => "scss".to_string(),
"json" => "json".to_string(),
"yaml" | "yml" => "yaml".to_string(),
"toml" => "toml".to_string(),
"md" | "markdown" => "md".to_string(),
"sql" => "sql".to_string(),
"lua" => "lua".to_string(),
"vim" => "vim".to_string(),
"dockerfile" => "dockerfile".to_string(),
"makefile" => "makefile".to_string(),
_ => default_lang,
}
} else if let Some(filename) = path.file_name().and_then(|name| name.to_str()) {
match filename.to_lowercase().as_str() {
"dockerfile" => "dockerfile".to_string(),
"makefile" => "makefile".to_string(),
"cargo.toml" | "pyproject.toml" => "toml".to_string(),
"package.json" => "json".to_string(),
_ => default_lang,
}
} else {
default_lang
}
}

View File

@ -29,7 +29,7 @@ pub(super) fn get_visual_line_mapping(
cache
.borrow()
.as_ref()
.map(|(_, _, mapping)| mapping.to_owned())
.map(|(_, _, mapping)| mapping.clone())
.unwrap_or_default()
})
}
@ -52,7 +52,7 @@ fn calculate_visual_line_mapping(
let galley = ui.fonts(|fonts| {
fonts.layout(
line.to_string(),
font_id.to_owned(),
font_id.clone(),
egui::Color32::WHITE,
available_width,
)
@ -86,7 +86,8 @@ pub(super) fn render_line_numbers(
let bg_color = ui.visuals().extreme_bg_color;
let line_numbers_rect = ui.available_rect_before_wrap();
ui.painter().rect_filled(line_numbers_rect, 0.0, bg_color);
ui.painter()
.rect_filled(line_numbers_rect, 0.0, bg_color);
let font_id = egui::FontId::monospace(font_size);
let line_count_width = line_count.to_string().len();
@ -100,7 +101,7 @@ pub(super) fn render_line_numbers(
};
ui.label(
egui::RichText::new(text)
.font(font_id.to_owned())
.font(font_id.clone())
.color(text_color),
);
}
@ -109,7 +110,7 @@ pub(super) fn render_line_numbers(
let text = format!("{:>width$}", i, width = line_count_width);
ui.label(
egui::RichText::new(text)
.font(font_id.to_owned())
.font(font_id.clone())
.color(text_color),
);
}

View File

@ -1,26 +0,0 @@
pub const SMALL: f32 = 4.0;
pub const MEDIUM: f32 = 8.0;
pub const LARGE: f32 = 12.0;
pub const VLARGE: f32 = 16.0;
pub const UI_HEADER_SIZE: f32 = 18.0;
pub const UI_TEXT_SIZE: f32 = 14.0;
pub const MIN_FONT_SIZE: f32 = 8.0;
pub const MAX_FONT_SIZE: f32 = 32.0;
pub const WINDOW_WIDTH_RATIO: f32 = 0.6;
pub const WINDOW_HEIGHT_RATIO: f32 = 0.7;
pub const WINDOW_MIN_WIDTH: f32 = 300.0;
pub const WINDOW_MAX_WIDTH: f32 = 400.0;
pub const WINDOW_MIN_HEIGHT: f32 = 250.0;
pub const WINDOW_MAX_HEIGHT: f32 = 500.0;
pub const CORNER_RADIUS: u8 = 8;
pub const FONT_SIZE_INPUT_WIDTH: f32 = 24.0;
pub const DEFAULT_FONT_SIZE_STR: &str = "14";
pub const PREVIEW_AREA_MAX_HEIGHT: f32 = 150.0;
pub const INNER_MARGIN: i8 = 8;

View File

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

View File

@ -1,6 +1,5 @@
use crate::{app::TextEditor, io};
use eframe::egui::{self, Frame};
use egui::UiKind;
pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
let now = std::time::Instant::now();
@ -12,12 +11,12 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
let should_show_menubar = !app.auto_hide_toolbar || {
if app.menu_interaction_active {
app.menu_bar_stable_until = Some(now + std::time::Duration::from_millis(16));
app.menu_bar_stable_until = Some(now + std::time::Duration::from_millis(500));
true
} else if should_stay_stable {
true
} else if let Some(pointer_pos) = ctx.pointer_hover_pos() {
let in_menu_trigger_area = pointer_pos.y < 5.0;
let in_menu_trigger_area = pointer_pos.y < 10.0;
if in_menu_trigger_area {
app.menu_bar_stable_until = Some(now + std::time::Duration::from_millis(300));
@ -44,34 +43,34 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
}
}
egui::MenuBar::new().ui(ui, |ui| {
egui::menu::bar(ui, |ui| {
ui.menu_button("File", |ui| {
app.menu_interaction_active = true;
if ui.button("New").clicked() {
io::new_file(app);
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
if ui.button("Open...").clicked() {
io::open_file(app);
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
ui.separator();
if ui.button("Save").clicked() {
io::save_file(app);
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
if ui.button("Save As...").clicked() {
io::save_as_file(app);
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
ui.separator();
if ui.button("Preferences").clicked() {
app.show_preferences = true;
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
if ui.button("Exit").clicked() {
app.request_quit(ctx);
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
});
@ -79,16 +78,16 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
app.menu_interaction_active = true;
if ui.button("Cut").clicked() {
ui.ctx().input_mut(|i| i.events.push(egui::Event::Cut));
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
if ui.button("Copy").clicked() {
ui.ctx().input_mut(|i| i.events.push(egui::Event::Copy));
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
if ui.button("Paste").clicked() {
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::RequestPaste);
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
if ui.button("Delete").clicked() {
ui.ctx().input_mut(|i| {
@ -100,7 +99,7 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
modifiers: egui::Modifiers::NONE,
})
});
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
if ui.button("Select All").clicked() {
let text_edit_id = egui::Id::new("main_text_editor");
@ -117,7 +116,7 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
egui::TextEdit::store_state(ui.ctx(), text_edit_id, state);
}
}
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
ui.separator();
if ui.button("Undo").clicked() {
@ -128,24 +127,21 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
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(),
active_tab.content.clone(),
);
let mut undoer = state.undoer();
if let Some((cursor_range, content)) =
undoer.undo(&current_state)
{
active_tab.content = content.to_string();
active_tab.content = content.clone();
state.cursor.set_char_range(Some(*cursor_range));
state.set_undoer(undoer);
egui::TextEdit::store_state(ui.ctx(), text_edit_id, state);
active_tab.update_modified_state();
if app.show_find && !app.find_query.is_empty() {
app.update_find_matches();
}
}
}
}
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
if ui.button("Redo").clicked() {
let text_edit_id = egui::Id::new("main_text_editor");
@ -155,57 +151,46 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
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(),
active_tab.content.clone(),
);
let mut undoer = state.undoer();
if let Some((cursor_range, content)) =
undoer.redo(&current_state)
{
active_tab.content = content.to_string();
active_tab.content = content.clone();
state.cursor.set_char_range(Some(*cursor_range));
state.set_undoer(undoer);
egui::TextEdit::store_state(ui.ctx(), text_edit_id, state);
active_tab.update_modified_state();
if app.show_find && !app.find_query.is_empty() {
app.update_find_matches();
}
}
}
}
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
});
ui.menu_button("View", |ui| {
app.menu_interaction_active = true;
if ui
.checkbox(&mut app.show_line_numbers, "Show Line Numbers")
.checkbox(&mut app.show_line_numbers, "Toggle Line Numbers")
.clicked()
{
app.save_config();
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
if ui
.checkbox(&mut app.syntax_highlighting, "Syntax Highlighting")
.checkbox(&mut app.word_wrap, "Toggle Word Wrap")
.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);
}
if ui.checkbox(&mut app.hide_tab_bar, "Hide Tab Bar").clicked() {
app.save_config();
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
if ui
.checkbox(&mut app.auto_hide_toolbar, "Auto Hide Toolbar")
.clicked()
{
app.save_config();
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
ui.separator();
@ -213,7 +198,7 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
if ui.button("Reset Zoom").clicked() {
app.zoom_factor = 1.0;
ctx.set_zoom_factor(1.0);
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
ui.separator();
@ -233,7 +218,7 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
if current_theme != crate::app::theme::Theme::System {
app.set_theme(ctx);
}
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
if ui
.radio_value(
@ -246,7 +231,7 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
if current_theme != crate::app::theme::Theme::Light {
app.set_theme(ctx);
}
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
if ui
.radio_value(&mut app.theme, crate::app::theme::Theme::Dark, "Dark")
@ -255,16 +240,16 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
if current_theme != crate::app::theme::Theme::Dark {
app.set_theme(ctx);
}
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
ui.separator();
if ui.radio_value(&mut app.line_side, false, "Left").clicked() {
app.save_config();
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
if ui.radio_value(&mut app.line_side, true, "Right").clicked() {
app.save_config();
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
});
});
@ -273,49 +258,13 @@ pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
app.menu_interaction_active = true;
if ui.button("Shortcuts").clicked() {
app.show_shortcuts = true;
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
if ui.button("About").clicked() {
app.show_about = true;
ui.close_kind(UiKind::Menu);
ui.close_menu();
}
});
if app.hide_tab_bar {
let tab_title = if let Some(tab) = app.get_active_tab() {
tab.get_display_title()
} else {
let empty_tab = crate::app::tab::Tab::new_empty(1);
empty_tab.get_display_title()
};
let window_width = ctx.screen_rect().width();
let font_id = ui.style().text_styles[&egui::TextStyle::Body].to_owned();
let 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

@ -1,14 +1,11 @@
use crate::app::TextEditor;
use crate::ui::constants::*;
use eframe::egui;
pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
let visuals = &ctx.style().visuals;
let screen_rect = ctx.screen_rect();
let window_width =
(screen_rect.width() * WINDOW_WIDTH_RATIO).clamp(WINDOW_MIN_WIDTH, WINDOW_MAX_WIDTH);
let window_height =
(screen_rect.height() * WINDOW_HEIGHT_RATIO).clamp(WINDOW_MIN_HEIGHT, WINDOW_MAX_HEIGHT);
let window_width = (screen_rect.width() * 0.6).min(400.0).max(300.0);
let window_height = (screen_rect.height() * 0.7).min(500.0).max(250.0);
let max_size = egui::Vec2::new(window_width, window_height);
egui::Window::new("Preferences")
@ -17,91 +14,23 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.default_open(true)
.max_size(max_size)
.fade_in(true)
.fade_out(true)
.frame(egui::Frame {
fill: visuals.window_fill,
stroke: visuals.window_stroke,
corner_radius: egui::CornerRadius::same(CORNER_RADIUS),
corner_radius: egui::CornerRadius::same(8),
shadow: visuals.window_shadow,
inner_margin: egui::Margin::same(INNER_MARGIN),
inner_margin: egui::Margin::same(16),
outer_margin: egui::Margin::same(0),
})
.show(ctx, |ui| {
ui.vertical_centered(|ui| {
ui.heading("Editor Settings");
ui.add_space(MEDIUM);
ui.horizontal(|ui| {
ui.vertical(|ui| {
if ui
.checkbox(&mut app.state_cache, "Maintain State")
.on_hover_text("Unsaved changes will be cached between sessions")
.changed()
{
app.save_config();
if !app.state_cache {
if let Err(e) = TextEditor::clear_state_cache() {
eprintln!("Failed to clear state cache: {e}");
}
}
}
ui.add_space(SMALL);
if ui
.checkbox(&mut app.show_line_numbers, "Show Line Numbers")
.changed()
{
app.save_config();
}
ui.add_space(SMALL);
if ui
.checkbox(&mut app.auto_hide_toolbar, "Auto Hide Toolbar")
.on_hover_text(
"Hide the top bar until you move your mouse to the upper edge",
)
.changed()
{
app.save_config();
}
});
ui.vertical(|ui| {
if ui.checkbox(&mut app.word_wrap, "Word Wrap").changed() {
app.save_config();
}
ui.add_space(SMALL);
if ui
.checkbox(&mut app.syntax_highlighting, "Syntax Highlighting")
.changed()
{
app.save_config();
}
ui.add_space(SMALL);
if ui
.checkbox(&mut app.hide_tab_bar, "Hide Tab Bar")
.on_hover_text(
"Hide the tab bar and show tab title in menu bar instead",
)
.changed()
{
app.save_config();
}
});
});
ui.add_space(SMALL);
ui.separator();
ui.add_space(LARGE);
ui.heading("Font Settings");
ui.add_space(MEDIUM);
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label("Font Family:");
ui.add_space(SMALL);
ui.label("Font Size:");
});
ui.add_space(5.0);
ui.vertical(|ui| {
let mut changed = false;
egui::ComboBox::from_id_salt("font_family")
.selected_text(&app.font_family)
@ -128,25 +57,30 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
}
});
if changed {
app.apply_font_settings(ctx);
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("Font Size:");
ui.add_space(5.0);
if app.font_size_input.is_none() {
app.font_size_input = Some(app.font_size.to_string());
}
let mut font_size_text = app
.font_size_input
.as_ref()
.unwrap_or(&DEFAULT_FONT_SIZE_STR.to_string())
.to_owned();
ui.add_space(SMALL);
ui.horizontal(|ui| {
let mut font_size_text = app.font_size_input.as_ref().unwrap().clone();
let response = ui.add(
egui::TextEdit::singleline(&mut font_size_text)
.desired_width(FONT_SIZE_INPUT_WIDTH)
.hint_text(DEFAULT_FONT_SIZE_STR)
.desired_width(50.0)
.hint_text("14")
.id(egui::Id::new("font_size_input")),
);
app.font_size_input = Some(font_size_text.to_owned());
app.font_size_input = Some(font_size_text.clone());
if response.clicked() {
response.request_focus();
@ -156,7 +90,7 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
if response.lost_focus() {
if let Ok(new_size) = font_size_text.parse::<f32>() {
let clamped_size = new_size.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE);
let clamped_size = new_size.clamp(8.0, 32.0);
if (app.font_size - clamped_size).abs() > 0.1 {
app.font_size = clamped_size;
app.apply_font_settings(ctx);
@ -164,26 +98,22 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
}
app.font_size_input = None;
}
if changed {
app.apply_font_settings(ctx);
}
})
});
});
ui.add_space(MEDIUM);
ui.add_space(12.0);
ui.separator();
ui.add_space(8.0);
ui.label("Preview:");
ui.add_space(SMALL);
ui.add_space(4.0);
egui::ScrollArea::vertical()
.max_height(PREVIEW_AREA_MAX_HEIGHT)
.max_height(150.0)
.show(ui, |ui| {
egui::Frame::new()
.fill(visuals.code_bg_color)
.stroke(visuals.widgets.noninteractive.bg_stroke)
.inner_margin(egui::Margin::same(INNER_MARGIN))
.inner_margin(egui::Margin::same(8))
.show(ui, |ui| {
let preview_font = egui::FontId::new(
app.font_size,
@ -193,27 +123,22 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
},
);
ui.label(
egui::RichText::new(
"The quick brown fox jumps over the lazy dog.",
)
.font(preview_font.to_owned()),
egui::RichText::new("The quick brown fox jumps over the lazy dog.")
.font(preview_font.clone()),
);
ui.label(
egui::RichText::new("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
.font(preview_font.to_owned()),
.font(preview_font.clone()),
);
ui.label(
egui::RichText::new("abcdefghijklmnopqrstuvwxyz")
.font(preview_font.to_owned()),
);
ui.label(
egui::RichText::new("1234567890 !@#$%^&*()")
.font(preview_font.to_owned()),
.font(preview_font.clone()),
);
ui.label(egui::RichText::new("1234567890 !@#$%^&*()").font(preview_font));
});
});
ui.add_space(LARGE);
ui.add_space(12.0);
if ui.button("Close").clicked() {
app.show_preferences = false;

View File

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

View File

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