initial commit
This commit is contained in:
commit
0444080284
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
Cargo.lock
|
||||||
|
/target
|
||||||
|
perf.*
|
||||||
|
flamegraph.*
|
||||||
13
Cargo.toml
Normal file
13
Cargo.toml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "ced"
|
||||||
|
version = "0.0.3"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
eframe = "0.31"
|
||||||
|
egui = "0.31"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
rfd = "0.15"
|
||||||
|
toml = "0.8"
|
||||||
|
dirs = "5.0"
|
||||||
|
libc = "0.2"
|
||||||
8
ced.desktop
Normal file
8
ced.desktop
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Name=Text Editor
|
||||||
|
Name[en_US]=Text Editor
|
||||||
|
Exec=/usr/bin/ced
|
||||||
|
Icon=editor
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Categories=Application;Graphical;
|
||||||
7
src/app.rs
Normal file
7
src/app.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod shortcuts;
|
||||||
|
pub mod state;
|
||||||
|
pub mod tab;
|
||||||
|
pub mod theme;
|
||||||
|
|
||||||
|
pub use state::TextEditor;
|
||||||
95
src/app/config.rs
Normal file
95
src/app/config.rs
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use super::theme::Theme;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub auto_hide_toolbar: bool,
|
||||||
|
pub show_line_numbers: bool,
|
||||||
|
pub word_wrap: bool,
|
||||||
|
pub theme: Theme,
|
||||||
|
pub line_side: bool,
|
||||||
|
pub font_family: String,
|
||||||
|
pub font_size: f32,
|
||||||
|
// pub vim_mode: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
auto_hide_toolbar: false,
|
||||||
|
show_line_numbers: false,
|
||||||
|
word_wrap: true,
|
||||||
|
theme: Theme::default(),
|
||||||
|
line_side: false,
|
||||||
|
font_family: "Proportional".to_string(),
|
||||||
|
font_size: 14.0,
|
||||||
|
// vim_mode: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn config_path() -> Option<PathBuf> {
|
||||||
|
let config_dir = if let Some(config_dir) = dirs::config_dir() {
|
||||||
|
config_dir.join("ced")
|
||||||
|
} else if let Some(home_dir) = dirs::home_dir() {
|
||||||
|
home_dir.join(".config").join("ced")
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(config_dir.join("config.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let config_path = match Self::config_path() {
|
||||||
|
Some(path) => path,
|
||||||
|
None => return Self::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !config_path.exists() {
|
||||||
|
let default_config = Self::default();
|
||||||
|
if let Err(e) = default_config.save() {
|
||||||
|
eprintln!("Failed to create default config file: {}", e);
|
||||||
|
}
|
||||||
|
return default_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
match std::fs::read_to_string(&config_path) {
|
||||||
|
Ok(content) => match toml::from_str::<Config>(&content) {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Failed to parse config file {}: {}",
|
||||||
|
config_path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Failed to read config file {}: {}",
|
||||||
|
config_path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let config_path = Self::config_path().ok_or("Cannot determine config directory")?;
|
||||||
|
|
||||||
|
if let Some(parent) = config_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = toml::to_string_pretty(self)?;
|
||||||
|
std::fs::write(&config_path, content)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
301
src/app/shortcuts.rs
Normal file
301
src/app/shortcuts.rs
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
use crate::app::state::TextEditor;
|
||||||
|
use crate::io;
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum ShortcutAction {
|
||||||
|
NewFile,
|
||||||
|
OpenFile,
|
||||||
|
SaveFile,
|
||||||
|
SaveAsFile,
|
||||||
|
NewTab,
|
||||||
|
CloseTab,
|
||||||
|
ToggleLineNumbers,
|
||||||
|
ToggleLineSide,
|
||||||
|
ToggleWordWrap,
|
||||||
|
ToggleAutoHideToolbar,
|
||||||
|
ToggleFind,
|
||||||
|
NextTab,
|
||||||
|
PrevTab,
|
||||||
|
PageUp,
|
||||||
|
PageDown,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
GlobalZoomIn,
|
||||||
|
GlobalZoomOut,
|
||||||
|
ResetZoom,
|
||||||
|
Escape,
|
||||||
|
Preferences,
|
||||||
|
ToggleVimMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShortcutDefinition = (egui::Modifiers, egui::Key, ShortcutAction);
|
||||||
|
|
||||||
|
fn get_shortcuts() -> Vec<ShortcutDefinition> {
|
||||||
|
vec![
|
||||||
|
(egui::Modifiers::CTRL, egui::Key::N, ShortcutAction::NewFile),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL,
|
||||||
|
egui::Key::O,
|
||||||
|
ShortcutAction::OpenFile,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL | egui::Modifiers::SHIFT,
|
||||||
|
egui::Key::S,
|
||||||
|
ShortcutAction::SaveAsFile,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL,
|
||||||
|
egui::Key::S,
|
||||||
|
ShortcutAction::SaveFile,
|
||||||
|
),
|
||||||
|
(egui::Modifiers::CTRL, egui::Key::T, ShortcutAction::NewTab),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL,
|
||||||
|
egui::Key::W,
|
||||||
|
ShortcutAction::CloseTab,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL,
|
||||||
|
egui::Key::F,
|
||||||
|
ShortcutAction::ToggleFind,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL | egui::Modifiers::SHIFT,
|
||||||
|
egui::Key::L,
|
||||||
|
ShortcutAction::ToggleLineSide,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL,
|
||||||
|
egui::Key::L,
|
||||||
|
ShortcutAction::ToggleLineNumbers,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL,
|
||||||
|
egui::Key::K,
|
||||||
|
ShortcutAction::ToggleWordWrap,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL,
|
||||||
|
egui::Key::H,
|
||||||
|
ShortcutAction::ToggleAutoHideToolbar,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL | egui::Modifiers::SHIFT,
|
||||||
|
egui::Key::Tab,
|
||||||
|
ShortcutAction::PrevTab,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL,
|
||||||
|
egui::Key::Tab,
|
||||||
|
ShortcutAction::NextTab,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::NONE,
|
||||||
|
egui::Key::PageUp,
|
||||||
|
ShortcutAction::PageUp,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::NONE,
|
||||||
|
egui::Key::PageDown,
|
||||||
|
ShortcutAction::PageDown,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL,
|
||||||
|
egui::Key::Equals,
|
||||||
|
ShortcutAction::ZoomIn,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL | egui::Modifiers::SHIFT,
|
||||||
|
egui::Key::Minus,
|
||||||
|
ShortcutAction::GlobalZoomOut,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL,
|
||||||
|
egui::Key::Minus,
|
||||||
|
ShortcutAction::ZoomOut,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL,
|
||||||
|
egui::Key::Plus,
|
||||||
|
ShortcutAction::GlobalZoomIn,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL,
|
||||||
|
egui::Key::Num0,
|
||||||
|
ShortcutAction::ResetZoom,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL,
|
||||||
|
egui::Key::P,
|
||||||
|
ShortcutAction::Preferences,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::CTRL | egui::Modifiers::SHIFT,
|
||||||
|
egui::Key::Period,
|
||||||
|
ShortcutAction::ToggleVimMode,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
egui::Modifiers::NONE,
|
||||||
|
egui::Key::Escape,
|
||||||
|
ShortcutAction::Escape,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_action(action: ShortcutAction, editor: &mut TextEditor, ctx: &egui::Context) -> bool {
|
||||||
|
match action {
|
||||||
|
ShortcutAction::NewFile => {
|
||||||
|
io::new_file(editor);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::OpenFile => {
|
||||||
|
io::open_file(editor);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::SaveFile => {
|
||||||
|
io::save_file(editor);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::SaveAsFile => {
|
||||||
|
io::save_as_file(editor);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::NewTab => {
|
||||||
|
editor.add_new_tab();
|
||||||
|
false
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::ToggleLineNumbers => {
|
||||||
|
editor.show_line_numbers = !editor.show_line_numbers;
|
||||||
|
editor.save_config();
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::ToggleLineSide => {
|
||||||
|
editor.line_side = !editor.line_side;
|
||||||
|
editor.save_config();
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::ToggleWordWrap => {
|
||||||
|
editor.word_wrap = !editor.word_wrap;
|
||||||
|
editor.save_config();
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::ToggleAutoHideToolbar => {
|
||||||
|
editor.auto_hide_toolbar = !editor.auto_hide_toolbar;
|
||||||
|
editor.save_config();
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::NextTab => {
|
||||||
|
let next_tab_index = editor.active_tab_index + 1;
|
||||||
|
if next_tab_index < editor.tabs.len() {
|
||||||
|
editor.switch_to_tab(next_tab_index);
|
||||||
|
} else {
|
||||||
|
editor.switch_to_tab(0);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::PrevTab => {
|
||||||
|
if editor.active_tab_index == 0 {
|
||||||
|
editor.switch_to_tab(editor.tabs.len() - 1);
|
||||||
|
} else {
|
||||||
|
editor.switch_to_tab(editor.active_tab_index - 1);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::PageUp => false,
|
||||||
|
ShortcutAction::PageDown => false,
|
||||||
|
ShortcutAction::ZoomIn => {
|
||||||
|
editor.font_size += 1.0;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
ShortcutAction::ZoomOut => {
|
||||||
|
editor.font_size -= 1.0;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
ShortcutAction::GlobalZoomIn => {
|
||||||
|
editor.zoom_factor += 0.1;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::GlobalZoomOut => {
|
||||||
|
editor.zoom_factor -= 0.1;
|
||||||
|
if editor.zoom_factor < 0.1 {
|
||||||
|
editor.zoom_factor = 0.1;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::ResetZoom => {
|
||||||
|
editor.zoom_factor = 1.0;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::ToggleVimMode => {
|
||||||
|
// editor.vim_mode = !editor.vim_mode;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::Escape => {
|
||||||
|
editor.show_about = false;
|
||||||
|
editor.show_shortcuts = false;
|
||||||
|
editor.show_find = false;
|
||||||
|
editor.show_preferences = false;
|
||||||
|
editor.pending_unsaved_action = None;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::ToggleFind => {
|
||||||
|
//editor.show_find = !editor.show_find;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShortcutAction::Preferences => {
|
||||||
|
editor.show_preferences = !editor.show_preferences;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle(editor: &mut TextEditor, ctx: &egui::Context) {
|
||||||
|
let mut font_zoom_occurred = false;
|
||||||
|
let mut global_zoom_occurred = false;
|
||||||
|
|
||||||
|
ctx.input_mut(|i| {
|
||||||
|
for (modifiers, key, action) in get_shortcuts() {
|
||||||
|
if i.consume_key(modifiers, key) {
|
||||||
|
match action {
|
||||||
|
ShortcutAction::ZoomIn | ShortcutAction::ZoomOut => {
|
||||||
|
font_zoom_occurred = execute_action(action, editor, ctx);
|
||||||
|
}
|
||||||
|
ShortcutAction::GlobalZoomIn
|
||||||
|
| ShortcutAction::GlobalZoomOut
|
||||||
|
| ShortcutAction::ResetZoom => {
|
||||||
|
execute_action(action, editor, ctx);
|
||||||
|
global_zoom_occurred = true;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
execute_action(action, editor, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if font_zoom_occurred {
|
||||||
|
editor.apply_font_settings(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if global_zoom_occurred {
|
||||||
|
ctx.set_zoom_factor(editor.zoom_factor);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/app/state.rs
Normal file
11
src/app/state.rs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
mod app_impl;
|
||||||
|
mod config;
|
||||||
|
mod default;
|
||||||
|
mod editor;
|
||||||
|
mod find;
|
||||||
|
mod lifecycle;
|
||||||
|
mod processing;
|
||||||
|
mod tabs;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
pub use editor::{TextEditor, UnsavedAction};
|
||||||
122
src/app/state/app_impl.rs
Normal file
122
src/app/state/app_impl.rs
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
use crate::app::shortcuts;
|
||||||
|
use crate::ui::{
|
||||||
|
about_window::about_window, central_panel::central_panel, find_window::find_window,
|
||||||
|
menu_bar::menu_bar, preferences_window::preferences_window, shortcuts_window::shortcuts_window,
|
||||||
|
tab_bar::tab_bar,
|
||||||
|
};
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
use super::editor::TextEditor;
|
||||||
|
|
||||||
|
impl eframe::App for TextEditor {
|
||||||
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
if ctx.input(|i| i.viewport().close_requested())
|
||||||
|
&& !self.force_quit_confirmed
|
||||||
|
&& !self.clean_quit_requested
|
||||||
|
{
|
||||||
|
self.request_quit(ctx);
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcuts::handle(self, ctx);
|
||||||
|
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.get_title()));
|
||||||
|
|
||||||
|
menu_bar(self, ctx);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
if self.show_about {
|
||||||
|
about_window(self, ctx);
|
||||||
|
}
|
||||||
|
if self.show_shortcuts {
|
||||||
|
shortcuts_window(self, ctx);
|
||||||
|
}
|
||||||
|
if self.show_preferences {
|
||||||
|
preferences_window(self, ctx);
|
||||||
|
}
|
||||||
|
if self.show_find {
|
||||||
|
find_window(self, ctx);
|
||||||
|
}
|
||||||
|
if self.pending_unsaved_action.is_some() {
|
||||||
|
self.show_unsaved_changes_dialog(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the previous find state for next frame
|
||||||
|
self.prev_show_find = self.show_find;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/app/state/config.rs
Normal file
99
src/app/state/config.rs
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
use super::editor::TextEditor;
|
||||||
|
use crate::app::config::Config;
|
||||||
|
use crate::app::tab::Tab;
|
||||||
|
use crate::app::theme;
|
||||||
|
|
||||||
|
impl TextEditor {
|
||||||
|
pub fn from_config(config: Config) -> Self {
|
||||||
|
Self {
|
||||||
|
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,
|
||||||
|
theme: config.theme,
|
||||||
|
line_side: config.line_side,
|
||||||
|
font_family: config.font_family,
|
||||||
|
font_size: config.font_size,
|
||||||
|
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<'_>) -> Self {
|
||||||
|
let mut editor = Self::from_config(config);
|
||||||
|
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()).clone();
|
||||||
|
style
|
||||||
|
.text_styles
|
||||||
|
.insert(egui::TextStyle::Body, egui::FontId::proportional(16.0));
|
||||||
|
style
|
||||||
|
.text_styles
|
||||||
|
.insert(egui::TextStyle::Button, egui::FontId::proportional(16.0));
|
||||||
|
style
|
||||||
|
.text_styles
|
||||||
|
.insert(egui::TextStyle::Heading, egui::FontId::proportional(22.0));
|
||||||
|
style
|
||||||
|
.text_styles
|
||||||
|
.insert(egui::TextStyle::Small, egui::FontId::proportional(14.0));
|
||||||
|
cc.egui_ctx.set_style(style);
|
||||||
|
|
||||||
|
editor.apply_font_settings(&cc.egui_ctx);
|
||||||
|
|
||||||
|
editor.start_text_processing_thread();
|
||||||
|
|
||||||
|
editor
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_config(&self) -> Config {
|
||||||
|
Config {
|
||||||
|
auto_hide_toolbar: self.auto_hide_toolbar,
|
||||||
|
show_line_numbers: self.show_line_numbers,
|
||||||
|
word_wrap: self.word_wrap,
|
||||||
|
theme: self.theme,
|
||||||
|
line_side: self.line_side,
|
||||||
|
font_family: self.font_family.clone(),
|
||||||
|
font_size: self.font_size,
|
||||||
|
// vim_mode: self.vim_mode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_config(&self) {
|
||||||
|
let config = self.get_config();
|
||||||
|
if let Err(e) = config.save() {
|
||||||
|
eprintln!("Failed to save configuration: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/app/state/default.rs
Normal file
52
src/app/state/default.rs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
use super::editor::TextEditor;
|
||||||
|
use super::editor::TextProcessingResult;
|
||||||
|
use crate::app::{tab::Tab, theme::Theme};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
impl Default for TextEditor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
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: false,
|
||||||
|
word_wrap: true,
|
||||||
|
auto_hide_toolbar: false,
|
||||||
|
theme: Theme::default(),
|
||||||
|
line_side: false,
|
||||||
|
font_family: "Proportional".to_string(),
|
||||||
|
font_size: 14.0,
|
||||||
|
font_size_input: None,
|
||||||
|
zoom_factor: 1.0,
|
||||||
|
menu_interaction_active: false,
|
||||||
|
tab_bar_rect: None,
|
||||||
|
menu_bar_stable_until: None,
|
||||||
|
text_processing_result: Arc::new(Mutex::new(TextProcessingResult::default())),
|
||||||
|
processing_thread_handle: None,
|
||||||
|
// Find functionality
|
||||||
|
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: 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/state/editor.rs
Normal file
75
src/app/state/editor.rs
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
use crate::app::tab::Tab;
|
||||||
|
use crate::app::theme::Theme;
|
||||||
|
use eframe::egui;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum UnsavedAction {
|
||||||
|
Quit,
|
||||||
|
CloseTab(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TextProcessingResult {
|
||||||
|
pub line_count: usize,
|
||||||
|
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,
|
||||||
|
visual_line_mapping: vec![Some(1)],
|
||||||
|
max_line_length: 0.0,
|
||||||
|
_processed_content: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive()]
|
||||||
|
pub struct TextEditor {
|
||||||
|
pub(crate) tabs: Vec<Tab>,
|
||||||
|
pub(crate) active_tab_index: usize,
|
||||||
|
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,
|
||||||
|
pub(crate) show_preferences: bool,
|
||||||
|
pub(crate) pending_unsaved_action: Option<UnsavedAction>,
|
||||||
|
pub(crate) force_quit_confirmed: bool,
|
||||||
|
pub(crate) clean_quit_requested: bool,
|
||||||
|
pub(crate) show_line_numbers: bool,
|
||||||
|
pub(crate) word_wrap: bool,
|
||||||
|
pub(crate) auto_hide_toolbar: bool,
|
||||||
|
pub(crate) theme: Theme,
|
||||||
|
pub(crate) line_side: bool,
|
||||||
|
pub(crate) font_family: String,
|
||||||
|
pub(crate) font_size: f32,
|
||||||
|
pub(crate) font_size_input: Option<String>,
|
||||||
|
pub(crate) zoom_factor: f32,
|
||||||
|
pub(crate) menu_interaction_active: bool,
|
||||||
|
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) find_query: String,
|
||||||
|
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) 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>,
|
||||||
|
}
|
||||||
75
src/app/state/find.rs
Normal file
75
src/app/state/find.rs
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
use super::editor::TextEditor;
|
||||||
|
|
||||||
|
impl TextEditor {
|
||||||
|
pub fn update_find_matches(&mut self) {
|
||||||
|
self.find_matches.clear();
|
||||||
|
self.current_match_index = None;
|
||||||
|
|
||||||
|
if self.find_query.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tab) = self.get_active_tab() {
|
||||||
|
let content = &tab.content;
|
||||||
|
let query = if self.case_sensitive_search {
|
||||||
|
self.find_query.clone()
|
||||||
|
} else {
|
||||||
|
self.find_query.to_lowercase()
|
||||||
|
};
|
||||||
|
|
||||||
|
let search_content = if self.case_sensitive_search {
|
||||||
|
content.clone()
|
||||||
|
} else {
|
||||||
|
content.to_lowercase()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut start = 0;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.find_matches.is_empty() {
|
||||||
|
self.current_match_index = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_next(&mut self) {
|
||||||
|
if self.find_matches.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(current) = self.current_match_index {
|
||||||
|
self.current_match_index = Some((current + 1) % self.find_matches.len());
|
||||||
|
} else {
|
||||||
|
self.current_match_index = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_previous(&mut self) {
|
||||||
|
if self.find_matches.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(current) = self.current_match_index {
|
||||||
|
self.current_match_index = Some(if current == 0 {
|
||||||
|
self.find_matches.len() - 1
|
||||||
|
} else {
|
||||||
|
current - 1
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
self.current_match_index = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_current_match_position(&self) -> Option<(usize, usize)> {
|
||||||
|
if let Some(index) = self.current_match_index {
|
||||||
|
self.find_matches.get(index).copied()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/app/state/lifecycle.rs
Normal file
124
src/app/state/lifecycle.rs
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
use super::editor::{TextEditor, UnsavedAction};
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
impl TextEditor {
|
||||||
|
pub fn has_unsaved_changes(&self) -> bool {
|
||||||
|
self.tabs.iter().any(|tab| tab.is_modified)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_unsaved_files(&self) -> Vec<String> {
|
||||||
|
self.tabs
|
||||||
|
.iter()
|
||||||
|
.filter(|tab| tab.is_modified)
|
||||||
|
.map(|tab| tab.title.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_quit(&mut self, ctx: &egui::Context) {
|
||||||
|
if self.has_unsaved_changes() {
|
||||||
|
self.pending_unsaved_action = Some(UnsavedAction::Quit);
|
||||||
|
} else {
|
||||||
|
self.clean_quit_requested = true;
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn force_quit(&mut self, ctx: &egui::Context) {
|
||||||
|
self.force_quit_confirmed = true;
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn show_unsaved_changes_dialog(&mut self, ctx: &egui::Context) {
|
||||||
|
let mut close_action_now = None;
|
||||||
|
let mut cancel_action = false;
|
||||||
|
|
||||||
|
let (files_to_list, title, confirmation_text, button_text, action) =
|
||||||
|
if let Some(action) = &self.pending_unsaved_action {
|
||||||
|
match action {
|
||||||
|
UnsavedAction::Quit => (
|
||||||
|
self.get_unsaved_files(),
|
||||||
|
"Unsaved Changes".to_string(),
|
||||||
|
"You have unsaved changes.".to_string(),
|
||||||
|
"Quit Without Saving".to_string(),
|
||||||
|
action.clone(),
|
||||||
|
),
|
||||||
|
UnsavedAction::CloseTab(tab_index) => {
|
||||||
|
let file_name = self
|
||||||
|
.tabs
|
||||||
|
.get(*tab_index)
|
||||||
|
.map_or_else(|| "unknown file".to_string(), |tab| tab.title.clone());
|
||||||
|
(
|
||||||
|
vec![file_name],
|
||||||
|
"Unsaved Changes".to_string(),
|
||||||
|
"The file has unsaved changes.".to_string(),
|
||||||
|
"Close Without Saving".to_string(),
|
||||||
|
action.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return; // Should not happen if called correctly
|
||||||
|
};
|
||||||
|
|
||||||
|
let visuals = &ctx.style().visuals;
|
||||||
|
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(|ui| {
|
||||||
|
ui.label(egui::RichText::new(&confirmation_text).size(14.0));
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
for file in &files_to_list {
|
||||||
|
ui.label(egui::RichText::new(format!("• {}", file)).size(18.0).weak());
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let cancel_fill = ui.visuals().widgets.inactive.bg_fill;
|
||||||
|
let cancel_stroke = ui.visuals().widgets.inactive.bg_stroke;
|
||||||
|
let cancel_button = egui::Button::new("Cancel")
|
||||||
|
.fill(cancel_fill)
|
||||||
|
.stroke(cancel_stroke);
|
||||||
|
|
||||||
|
if ui.add(cancel_button).clicked() {
|
||||||
|
cancel_action = true;
|
||||||
|
}
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if cancel_action {
|
||||||
|
self.pending_unsaved_action = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(action) = close_action_now {
|
||||||
|
match action {
|
||||||
|
UnsavedAction::Quit => self.force_quit(ctx),
|
||||||
|
UnsavedAction::CloseTab(tab_index) => {
|
||||||
|
self.close_tab(tab_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.pending_unsaved_action = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/app/state/processing.rs
Normal file
63
src/app/state/processing.rs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use super::editor::{TextEditor, TextProcessingResult};
|
||||||
|
|
||||||
|
impl TextEditor {
|
||||||
|
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, ¶m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
match handle {
|
||||||
|
Ok(h) => self.processing_thread_handle = Some(h),
|
||||||
|
Err(e) => eprintln!("Failed to start text processing thread: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
(1..=line_count).map(Some).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = TextProcessingResult {
|
||||||
|
line_count,
|
||||||
|
visual_line_mapping,
|
||||||
|
max_line_length: available_width,
|
||||||
|
_processed_content: content.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/app/state/tabs.rs
Normal file
35
src/app/state/tabs.rs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
use super::editor::TextEditor;
|
||||||
|
use crate::app::tab::Tab;
|
||||||
|
|
||||||
|
impl TextEditor {
|
||||||
|
pub fn get_active_tab(&self) -> Option<&Tab> {
|
||||||
|
self.tabs.get(self.active_tab_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_active_tab_mut(&mut self) -> Option<&mut Tab> {
|
||||||
|
self.tabs.get_mut(self.active_tab_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_new_tab(&mut self) {
|
||||||
|
self.tab_counter += 1;
|
||||||
|
self.tabs.push(Tab::new_empty(self.tab_counter));
|
||||||
|
self.active_tab_index = self.tabs.len() - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close_tab(&mut self, tab_index: usize) {
|
||||||
|
if self.tabs.len() > 1 && tab_index < self.tabs.len() {
|
||||||
|
self.tabs.remove(tab_index);
|
||||||
|
if self.active_tab_index >= self.tabs.len() {
|
||||||
|
self.active_tab_index = self.tabs.len() - 1;
|
||||||
|
} else if self.active_tab_index > tab_index {
|
||||||
|
self.active_tab_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn switch_to_tab(&mut self, tab_index: usize) {
|
||||||
|
if tab_index < self.tabs.len() {
|
||||||
|
self.active_tab_index = tab_index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
162
src/app/state/ui.rs
Normal file
162
src/app/state/ui.rs
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
use super::editor::TextEditor;
|
||||||
|
use crate::app::theme;
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
pub struct EditorDimensions {
|
||||||
|
pub text_width: f32,
|
||||||
|
pub line_number_width: f32,
|
||||||
|
pub total_reserved_width: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
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!("{}{} - C-Text", tab.title, modified_indicator)
|
||||||
|
} else {
|
||||||
|
"C-Text".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
};
|
||||||
|
egui::FontId::new(self.font_size, font_family)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_theme(&mut self, ctx: &egui::Context) {
|
||||||
|
theme::apply(self.theme, ctx);
|
||||||
|
self.save_config();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_font_settings(&mut self, ctx: &egui::Context) {
|
||||||
|
let font_family = match self.font_family.as_str() {
|
||||||
|
"Monospace" => egui::FontFamily::Monospace,
|
||||||
|
_ => egui::FontFamily::Proportional,
|
||||||
|
};
|
||||||
|
|
||||||
|
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.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();
|
||||||
|
|
||||||
|
if !self.show_line_numbers {
|
||||||
|
return EditorDimensions {
|
||||||
|
text_width: total_available_width,
|
||||||
|
line_number_width: 0.0,
|
||||||
|
total_reserved_width: 0.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
let base_line_number_width = ui.fonts(|fonts| {
|
||||||
|
fonts
|
||||||
|
.layout(sample_text, font_id, egui::Color32::WHITE, f32::INFINITY)
|
||||||
|
.size()
|
||||||
|
.x
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add padding based on line_side setting
|
||||||
|
let line_number_width = if self.line_side {
|
||||||
|
base_line_number_width + 20.0 // Extra padding when line numbers are on the side
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
let separator_width = 10.0;
|
||||||
|
|
||||||
|
let total_reserved_width = line_number_width + separator_width;
|
||||||
|
let text_width = (total_available_width - total_reserved_width).max(100.0); // Minimum 100px for text
|
||||||
|
|
||||||
|
EditorDimensions {
|
||||||
|
text_width,
|
||||||
|
line_number_width,
|
||||||
|
total_reserved_width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the available width for non-word-wrapped content based on content analysis
|
||||||
|
pub fn calculate_content_based_width(&self, ui: &egui::Ui) -> f32 {
|
||||||
|
if let Some(active_tab) = self.get_active_tab() {
|
||||||
|
let content = &active_tab.content;
|
||||||
|
|
||||||
|
if content.is_empty() {
|
||||||
|
return self.calculate_editor_dimensions(ui).text_width;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/app/tab.rs
Normal file
80
src/app/tab.rs
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub fn compute_content_hash(content: &str, hasher: &mut DefaultHasher) -> u64 {
|
||||||
|
content.hash(hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
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 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),
|
||||||
|
hasher,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_with_file(content: String, file_path: PathBuf) -> Self {
|
||||||
|
let title = file_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("Untitled")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_display_title(&self) -> String {
|
||||||
|
let modified_indicator = if self.is_modified { "*" } else { "" };
|
||||||
|
format!("{}{}", self.title, modified_indicator)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, &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) {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/app/theme.rs
Normal file
196
src/app/theme.rs
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize, Default)]
|
||||||
|
pub enum Theme {
|
||||||
|
#[default]
|
||||||
|
System,
|
||||||
|
Light,
|
||||||
|
Dark,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply(theme: Theme, ctx: &egui::Context) {
|
||||||
|
match theme {
|
||||||
|
Theme::System => {
|
||||||
|
if let Some(system_visuals) = get_system_colors() {
|
||||||
|
ctx.set_visuals(system_visuals);
|
||||||
|
} else {
|
||||||
|
let is_dark = detect_system_dark_mode();
|
||||||
|
if is_dark {
|
||||||
|
ctx.set_visuals(egui::Visuals::dark());
|
||||||
|
} else {
|
||||||
|
ctx.set_visuals(egui::Visuals::light());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Theme::Light => {
|
||||||
|
ctx.set_visuals(egui::Visuals::light());
|
||||||
|
}
|
||||||
|
Theme::Dark => {
|
||||||
|
ctx.set_visuals(egui::Visuals::dark());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_system_colors() -> Option<egui::Visuals> {
|
||||||
|
if let Some(visuals) = get_pywal_colors() {
|
||||||
|
return Some(visuals);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if let Some(visuals) = get_gtk_colors() {
|
||||||
|
return Some(visuals);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_pywal_colors() -> Option<egui::Visuals> {
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
let home = std::env::var("HOME").ok()?;
|
||||||
|
let colors_path = Path::new(&home).join(".cache/wal/colors");
|
||||||
|
|
||||||
|
if !colors_path.exists() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let colors_content = fs::read_to_string(&colors_path).ok()?;
|
||||||
|
let colors: Vec<&str> = colors_content.lines().collect();
|
||||||
|
|
||||||
|
if colors.len() < 8 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parse_color = |hex: &str| -> Option<egui::Color32> {
|
||||||
|
if hex.len() != 7 || !hex.starts_with('#') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let r = u8::from_str_radix(&hex[1..3], 16).ok()?;
|
||||||
|
let g = u8::from_str_radix(&hex[3..5], 16).ok()?;
|
||||||
|
let b = u8::from_str_radix(&hex[5..7], 16).ok()?;
|
||||||
|
Some(egui::Color32::from_rgb(r, g, b))
|
||||||
|
};
|
||||||
|
|
||||||
|
let bg = parse_color(colors[0])?;
|
||||||
|
let fg = parse_color(colors.get(7).unwrap_or(&colors[0]))?;
|
||||||
|
let bg_alt = parse_color(colors.get(8).unwrap_or(&colors[0]))?;
|
||||||
|
let accent = parse_color(colors.get(1).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()
|
||||||
|
} else {
|
||||||
|
egui::Visuals::light()
|
||||||
|
};
|
||||||
|
|
||||||
|
visuals.window_fill = bg;
|
||||||
|
visuals.extreme_bg_color = bg;
|
||||||
|
visuals.code_bg_color = bg;
|
||||||
|
visuals.panel_fill = bg;
|
||||||
|
|
||||||
|
visuals.faint_bg_color = blend_colors(bg, bg_alt, 0.15);
|
||||||
|
visuals.error_fg_color = parse_color(colors.get(1).unwrap_or(&colors[0]))?;
|
||||||
|
|
||||||
|
visuals.override_text_color = Some(fg);
|
||||||
|
|
||||||
|
visuals.hyperlink_color = accent;
|
||||||
|
visuals.selection.bg_fill = blend_colors(accent, bg, 0.3);
|
||||||
|
visuals.selection.stroke.color = accent;
|
||||||
|
|
||||||
|
let separator_color = blend_colors(fg, bg, 0.3);
|
||||||
|
|
||||||
|
visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, separator_color);
|
||||||
|
visuals.widgets.noninteractive.bg_fill = bg;
|
||||||
|
visuals.widgets.noninteractive.fg_stroke.color = fg;
|
||||||
|
|
||||||
|
visuals.widgets.inactive.bg_fill = blend_colors(bg, accent, 0.2);
|
||||||
|
visuals.widgets.inactive.fg_stroke.color = fg;
|
||||||
|
visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, blend_colors(accent, bg, 0.4));
|
||||||
|
visuals.widgets.inactive.weak_bg_fill = blend_colors(bg, accent, 0.1);
|
||||||
|
|
||||||
|
visuals.widgets.hovered.bg_fill = blend_colors(bg, accent, 0.3);
|
||||||
|
visuals.widgets.hovered.fg_stroke.color = fg;
|
||||||
|
visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, accent);
|
||||||
|
visuals.widgets.hovered.weak_bg_fill = blend_colors(bg, accent, 0.15);
|
||||||
|
|
||||||
|
visuals.widgets.active.bg_fill = blend_colors(bg, accent, 0.4);
|
||||||
|
visuals.widgets.active.fg_stroke.color = fg;
|
||||||
|
visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, accent);
|
||||||
|
visuals.widgets.active.weak_bg_fill = blend_colors(bg, accent, 0.2);
|
||||||
|
|
||||||
|
visuals.window_stroke = egui::Stroke::new(1.0, separator_color);
|
||||||
|
|
||||||
|
visuals.widgets.open.bg_fill = blend_colors(bg, accent, 0.25);
|
||||||
|
visuals.widgets.open.fg_stroke.color = fg;
|
||||||
|
visuals.widgets.open.bg_stroke = egui::Stroke::new(1.0, accent);
|
||||||
|
visuals.widgets.open.weak_bg_fill = blend_colors(bg, accent, 0.15);
|
||||||
|
|
||||||
|
visuals.striped = true;
|
||||||
|
|
||||||
|
visuals.button_frame = true;
|
||||||
|
visuals.collapsing_header_frame = false;
|
||||||
|
|
||||||
|
Some(visuals)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_gtk_colors() -> Option<egui::Visuals> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_dark_color(color: egui::Color32) -> bool {
|
||||||
|
let r = color.r() as f32 / 255.0;
|
||||||
|
let g = color.g() as f32 / 255.0;
|
||||||
|
let b = color.b() as f32 / 255.0;
|
||||||
|
|
||||||
|
let r_lin = if r <= 0.04045 {
|
||||||
|
r / 12.92
|
||||||
|
} else {
|
||||||
|
((r + 0.055) / 1.055).powf(2.4)
|
||||||
|
};
|
||||||
|
let g_lin = if g <= 0.04045 {
|
||||||
|
g / 12.92
|
||||||
|
} else {
|
||||||
|
((g + 0.055) / 1.055).powf(2.4)
|
||||||
|
};
|
||||||
|
let b_lin = if b <= 0.04045 {
|
||||||
|
b / 12.92
|
||||||
|
} else {
|
||||||
|
((b + 0.055) / 1.055).powf(2.4)
|
||||||
|
};
|
||||||
|
|
||||||
|
let luminance = 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin;
|
||||||
|
|
||||||
|
luminance < 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blend_colors(base: egui::Color32, blend: egui::Color32, factor: f32) -> egui::Color32 {
|
||||||
|
let factor = factor.clamp(0.0, 1.0);
|
||||||
|
let inv_factor = 1.0 - factor;
|
||||||
|
|
||||||
|
egui::Color32::from_rgb(
|
||||||
|
(base.r() as f32 * inv_factor + blend.r() as f32 * factor) as u8,
|
||||||
|
(base.g() as f32 * inv_factor + blend.g() as f32 * factor) as u8,
|
||||||
|
(base.b() as f32 * inv_factor + blend.b() as f32 * factor) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_system_dark_mode() -> bool {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
true
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
true
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
true
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||||
|
{
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/io.rs
Normal file
88
src/io.rs
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
use crate::app::tab::Tab;
|
||||||
|
use crate::app::TextEditor;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub(crate) fn new_file(app: &mut TextEditor) {
|
||||||
|
app.add_new_tab();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn open_file(app: &mut TextEditor) {
|
||||||
|
if let Some(path) = rfd::FileDialog::new()
|
||||||
|
.add_filter("Text files", &["*"])
|
||||||
|
.pick_file()
|
||||||
|
{
|
||||||
|
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()
|
||||||
|
&& !active_tab.is_modified
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
if should_replace_current_tab {
|
||||||
|
// Replace the current empty tab
|
||||||
|
if let Some(active_tab) = app.get_active_tab_mut() {
|
||||||
|
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")
|
||||||
|
.to_string();
|
||||||
|
active_tab.mark_as_saved(); // This will set the hash and mark as not modified
|
||||||
|
}
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Failed to open file: {}", 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.clone());
|
||||||
|
} else {
|
||||||
|
save_as_file(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn save_as_file(app: &mut TextEditor) {
|
||||||
|
if let Some(path) = rfd::FileDialog::new()
|
||||||
|
.add_filter("Text files", &["*"])
|
||||||
|
.save_file()
|
||||||
|
{
|
||||||
|
save_to_path(app, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(()) => {
|
||||||
|
active_tab.file_path = Some(path.clone());
|
||||||
|
active_tab.title = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("Untitled")
|
||||||
|
.to_string();
|
||||||
|
active_tab.mark_as_saved();
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Failed to save file: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/main.rs
Normal file
26
src/main.rs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod io;
|
||||||
|
mod ui;
|
||||||
|
use app::{TextEditor, config::Config};
|
||||||
|
|
||||||
|
fn main() -> eframe::Result {
|
||||||
|
let options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default()
|
||||||
|
.with_min_inner_size([600.0, 400.0])
|
||||||
|
.with_title("C-Ext")
|
||||||
|
.with_app_id("io.lampnet.c-ext"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = Config::load();
|
||||||
|
|
||||||
|
eframe::run_native(
|
||||||
|
"C-Ext",
|
||||||
|
options,
|
||||||
|
Box::new(move |cc| Ok(Box::new(TextEditor::from_config_with_context(config, cc)))),
|
||||||
|
)
|
||||||
|
}
|
||||||
7
src/ui.rs
Normal file
7
src/ui.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
pub(crate) mod about_window;
|
||||||
|
pub(crate) mod central_panel;
|
||||||
|
pub(crate) mod find_window;
|
||||||
|
pub(crate) mod menu_bar;
|
||||||
|
pub(crate) mod preferences_window;
|
||||||
|
pub(crate) mod shortcuts_window;
|
||||||
|
pub(crate) mod tab_bar;
|
||||||
41
src/ui/about_window.rs
Normal file
41
src/ui/about_window.rs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
use crate::app::TextEditor;
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
pub(crate) fn about_window(app: &mut TextEditor, ctx: &egui::Context) {
|
||||||
|
let visuals = &ctx.style().visuals;
|
||||||
|
|
||||||
|
egui::Window::new(format!(
|
||||||
|
"Candle's Editor (ced {})",
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
))
|
||||||
|
.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.label(
|
||||||
|
egui::RichText::new("A stupidly simple, responsive text editor.")
|
||||||
|
.size(14.0)
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
let visuals = ui.visuals();
|
||||||
|
let close_button = egui::Button::new("Close")
|
||||||
|
.fill(visuals.widgets.inactive.bg_fill)
|
||||||
|
.stroke(visuals.widgets.inactive.bg_stroke);
|
||||||
|
|
||||||
|
if ui.add(close_button).clicked() {
|
||||||
|
app.show_about = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
112
src/ui/central_panel.rs
Normal file
112
src/ui/central_panel.rs
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
mod editor;
|
||||||
|
mod find_highlight;
|
||||||
|
mod line_numbers;
|
||||||
|
|
||||||
|
use crate::app::TextEditor;
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
use self::editor::editor_view_ui;
|
||||||
|
use self::line_numbers::{get_visual_line_mapping, render_line_numbers};
|
||||||
|
|
||||||
|
pub(crate) fn central_panel(app: &mut TextEditor, ctx: &egui::Context) {
|
||||||
|
let show_line_numbers = app.show_line_numbers;
|
||||||
|
let word_wrap = app.word_wrap;
|
||||||
|
let line_side = app.line_side;
|
||||||
|
let font_size = app.font_size;
|
||||||
|
|
||||||
|
egui::CentralPanel::default()
|
||||||
|
.frame(egui::Frame::NONE)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
let bg_color = ui.visuals().extreme_bg_color;
|
||||||
|
let panel_rect = ui.available_rect_before_wrap();
|
||||||
|
ui.painter().rect_filled(panel_rect, 0.0, bg_color);
|
||||||
|
let editor_height = panel_rect.height();
|
||||||
|
|
||||||
|
if !show_line_numbers || app.get_active_tab().is_none() {
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.auto_shrink([false; 2])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
editor_view_ui(ui, app);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let line_count = app.get_text_processing_result().line_count;
|
||||||
|
let editor_dimensions = app.calculate_editor_dimensions(ui);
|
||||||
|
let line_number_width = editor_dimensions.line_number_width;
|
||||||
|
|
||||||
|
let visual_line_mapping = if word_wrap {
|
||||||
|
let available_text_width = editor_dimensions.text_width;
|
||||||
|
if let Some(active_tab) = app.get_active_tab() {
|
||||||
|
get_visual_line_mapping(
|
||||||
|
ui,
|
||||||
|
&active_tab.content,
|
||||||
|
available_text_width,
|
||||||
|
font_size,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
|
||||||
|
let line_numbers_widget = |ui: &mut egui::Ui| {
|
||||||
|
render_line_numbers(
|
||||||
|
ui,
|
||||||
|
line_count,
|
||||||
|
&visual_line_mapping,
|
||||||
|
line_number_width,
|
||||||
|
word_wrap,
|
||||||
|
font_size,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let separator_widget = |ui: &mut egui::Ui| {
|
||||||
|
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; // Extend separator to cover more vertical space
|
||||||
|
ui.painter()
|
||||||
|
.vline(separator_x, y_range, ui.visuals().window_stroke);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
};
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.auto_shrink([false; 2])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
if line_side {
|
||||||
|
// 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| {
|
||||||
|
editor_view_ui(ui, app);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
separator_widget(ui);
|
||||||
|
line_numbers_widget(ui);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 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);
|
||||||
|
editor_view_ui(ui, app);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
222
src/ui/central_panel/editor.rs
Normal file
222
src/ui/central_panel/editor.rs
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
use crate::app::TextEditor;
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
use super::find_highlight::draw_find_highlight;
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset zoom if requested
|
||||||
|
if should_reset_zoom {
|
||||||
|
app.zoom_factor = 1.0;
|
||||||
|
ui.ctx().set_zoom_factor(1.0);
|
||||||
|
ui.ctx().memory_mut(|mem| {
|
||||||
|
mem.data.insert_temp(reset_zoom_key, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
let desired_width = if word_wrap {
|
||||||
|
ui.available_width()
|
||||||
|
} else {
|
||||||
|
f32::INFINITY
|
||||||
|
};
|
||||||
|
|
||||||
|
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(true)
|
||||||
|
.cursor_at_end(false)
|
||||||
|
.id(egui::Id::new("main_text_editor"));
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
if ui.button("Select All").clicked() {
|
||||||
|
let text_edit_id = egui::Id::new("main_text_editor");
|
||||||
|
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) {
|
||||||
|
let select_all_range = egui::text::CCursorRange::two(
|
||||||
|
egui::text::CCursor::new(0),
|
||||||
|
egui::text::CCursor::new(text_len),
|
||||||
|
);
|
||||||
|
state.cursor.set_char_range(Some(select_all_range));
|
||||||
|
egui::TextEdit::store_state(ui.ctx(), text_edit_id, state);
|
||||||
|
}
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if ui.button("Reset Zoom").clicked() {
|
||||||
|
ui.ctx().memory_mut(|mem| {
|
||||||
|
mem.data.insert_temp(reset_zoom_key, true);
|
||||||
|
});
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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))
|
||||||
|
.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);
|
||||||
|
let cursor_rect = egui::Rect::from_min_size(
|
||||||
|
egui::pos2(output.response.rect.left(), y_pos),
|
||||||
|
egui::vec2(2.0, line_height),
|
||||||
|
);
|
||||||
|
Some(cursor_rect)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
&& !show_shortcuts
|
||||||
|
&& !show_find
|
||||||
|
{
|
||||||
|
output.response.request_focus();
|
||||||
|
}
|
||||||
|
(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/ui/central_panel/find_highlight.rs
Normal file
83
src/ui/central_panel/find_highlight.rs
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
pub(super) fn draw_find_highlight(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
content: &str,
|
||||||
|
start_pos: usize,
|
||||||
|
end_pos: usize,
|
||||||
|
editor_rect: egui::Rect,
|
||||||
|
font_size: f32,
|
||||||
|
) {
|
||||||
|
let font_id = ui
|
||||||
|
.style()
|
||||||
|
.text_styles
|
||||||
|
.get(&egui::TextStyle::Monospace)
|
||||||
|
.unwrap_or(&egui::FontId::monospace(font_size))
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let text_up_to_start = &content[..start_pos.min(content.len())];
|
||||||
|
|
||||||
|
let start_line = text_up_to_start.chars().filter(|&c| c == '\n').count();
|
||||||
|
|
||||||
|
let line_start_byte_pos = text_up_to_start.rfind('\n').map(|pos| pos + 1).unwrap_or(0);
|
||||||
|
let line_start_char_pos = content[..line_start_byte_pos].chars().count();
|
||||||
|
let 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();
|
||||||
|
if start_line >= lines.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let line_text = lines[start_line];
|
||||||
|
let text_before_match: String = line_text.chars().take(start_col).collect();
|
||||||
|
|
||||||
|
let 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.clone(),
|
||||||
|
egui::Color32::WHITE,
|
||||||
|
f32::INFINITY,
|
||||||
|
)
|
||||||
|
.size()
|
||||||
|
.x
|
||||||
|
});
|
||||||
|
|
||||||
|
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.clone(),
|
||||||
|
ui.visuals().text_color(),
|
||||||
|
f32::INFINITY,
|
||||||
|
)
|
||||||
|
.size()
|
||||||
|
.x
|
||||||
|
});
|
||||||
|
|
||||||
|
let highlight_rect = egui::Rect::from_min_size(
|
||||||
|
egui::pos2(start_x, start_y),
|
||||||
|
egui::vec2(match_width, line_height),
|
||||||
|
);
|
||||||
|
|
||||||
|
ui.painter().rect_filled(
|
||||||
|
highlight_rect,
|
||||||
|
0.0,
|
||||||
|
ui.visuals().selection.bg_fill,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/ui/central_panel/line_numbers.rs
Normal file
119
src/ui/central_panel/line_numbers.rs
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static VISUAL_LINE_MAPPING_CACHE: std::cell::RefCell<Option<(String, f32, Vec<Option<usize>>)>> = std::cell::RefCell::new(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn get_visual_line_mapping(
|
||||||
|
ui: &egui::Ui,
|
||||||
|
content: &str,
|
||||||
|
available_width: f32,
|
||||||
|
font_size: f32,
|
||||||
|
) -> Vec<Option<usize>> {
|
||||||
|
let should_recalculate = VISUAL_LINE_MAPPING_CACHE.with(|cache| {
|
||||||
|
if let Some((cached_content, cached_width, _)) = cache.borrow().as_ref() {
|
||||||
|
content != cached_content || available_width != *cached_width
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if should_recalculate {
|
||||||
|
let visual_lines = calculate_visual_line_mapping(ui, content, available_width, font_size);
|
||||||
|
VISUAL_LINE_MAPPING_CACHE.with(|cache| {
|
||||||
|
*cache.borrow_mut() = Some((content.to_owned(), available_width, visual_lines));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
VISUAL_LINE_MAPPING_CACHE.with(|cache| {
|
||||||
|
cache
|
||||||
|
.borrow()
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, _, mapping)| mapping.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_visual_line_mapping(
|
||||||
|
ui: &egui::Ui,
|
||||||
|
content: &str,
|
||||||
|
available_width: f32,
|
||||||
|
font_size: f32,
|
||||||
|
) -> Vec<Option<usize>> {
|
||||||
|
let mut visual_lines = Vec::new();
|
||||||
|
let font_id = egui::FontId::monospace(font_size);
|
||||||
|
|
||||||
|
for (line_num, line) in content.lines().enumerate() {
|
||||||
|
if line.is_empty() {
|
||||||
|
visual_lines.push(Some(line_num + 1));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let galley = ui.fonts(|fonts| {
|
||||||
|
fonts.layout(
|
||||||
|
line.to_string(),
|
||||||
|
font_id.clone(),
|
||||||
|
egui::Color32::WHITE,
|
||||||
|
available_width,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let wrapped_line_count = galley.rows.len().max(1);
|
||||||
|
|
||||||
|
visual_lines.push(Some(line_num + 1));
|
||||||
|
|
||||||
|
for _ in 1..wrapped_line_count {
|
||||||
|
visual_lines.push(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visual_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn render_line_numbers(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
line_count: usize,
|
||||||
|
visual_line_mapping: &[Option<usize>],
|
||||||
|
line_number_width: f32,
|
||||||
|
word_wrap: bool,
|
||||||
|
font_size: f32,
|
||||||
|
) {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.set_width(line_number_width);
|
||||||
|
ui.spacing_mut().item_spacing.y = 0.0;
|
||||||
|
|
||||||
|
let text_color = ui.visuals().weak_text_color();
|
||||||
|
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);
|
||||||
|
|
||||||
|
let font_id = egui::FontId::monospace(font_size);
|
||||||
|
let line_count_width = line_count.to_string().len();
|
||||||
|
|
||||||
|
if word_wrap {
|
||||||
|
for line_number_opt in visual_line_mapping {
|
||||||
|
let text = if let Some(line_number) = line_number_opt {
|
||||||
|
format!("{:>width$}", line_number, width = line_count_width)
|
||||||
|
} else {
|
||||||
|
" ".repeat(line_count_width)
|
||||||
|
};
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(text)
|
||||||
|
.font(font_id.clone())
|
||||||
|
.color(text_color),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for i in 1..=line_count {
|
||||||
|
let text = format!("{:>width$}", i, width = line_count_width);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(text)
|
||||||
|
.font(font_id.clone())
|
||||||
|
.color(text_color),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
121
src/ui/find_window.rs
Normal file
121
src/ui/find_window.rs
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
use crate::app::TextEditor;
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
pub(crate) fn find_window(app: &mut TextEditor, ctx: &egui::Context) {
|
||||||
|
let visuals = &ctx.style().visuals;
|
||||||
|
|
||||||
|
let mut should_close = false;
|
||||||
|
let mut query_changed = false;
|
||||||
|
|
||||||
|
egui::Window::new("Find")
|
||||||
|
.collapsible(false)
|
||||||
|
.resizable(false)
|
||||||
|
.movable(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(8),
|
||||||
|
shadow: visuals.window_shadow,
|
||||||
|
inner_margin: egui::Margin::same(16),
|
||||||
|
outer_margin: egui::Margin::same(0),
|
||||||
|
})
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
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(200.0)
|
||||||
|
.hint_text("Enter search text..."),
|
||||||
|
);
|
||||||
|
|
||||||
|
if response.changed() {
|
||||||
|
query_changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !response.has_focus() {
|
||||||
|
response.request_focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||||
|
app.find_next();
|
||||||
|
response.request_focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let case_sensitive_changed = ui
|
||||||
|
.checkbox(&mut app.case_sensitive_search, "Case sensitive")
|
||||||
|
.changed();
|
||||||
|
if case_sensitive_changed {
|
||||||
|
query_changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let match_text = if app.find_matches.is_empty() {
|
||||||
|
if app.find_query.is_empty() {
|
||||||
|
"".to_string()
|
||||||
|
} else {
|
||||||
|
"No matches found".to_string()
|
||||||
|
}
|
||||||
|
} else if let Some(current) = app.current_match_index {
|
||||||
|
format!("{} of {} matches", current + 1, app.find_matches.len())
|
||||||
|
} else {
|
||||||
|
format!("{} matches found", app.find_matches.len())
|
||||||
|
};
|
||||||
|
|
||||||
|
ui.label(egui::RichText::new(match_text).weak());
|
||||||
|
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui.button("✕").clicked() {
|
||||||
|
should_close = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let prev_enabled = !app.find_matches.is_empty();
|
||||||
|
ui.add_enabled_ui(prev_enabled, |ui| {
|
||||||
|
if ui.button("Previous").clicked() {
|
||||||
|
app.find_previous();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if query_changed {
|
||||||
|
app.update_find_matches();
|
||||||
|
}
|
||||||
|
|
||||||
|
if should_close {
|
||||||
|
app.show_find = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.input(|i| {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
271
src/ui/menu_bar.rs
Normal file
271
src/ui/menu_bar.rs
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
use crate::{app::TextEditor, io};
|
||||||
|
use eframe::egui::{self, Frame};
|
||||||
|
|
||||||
|
pub(crate) fn menu_bar(app: &mut TextEditor, ctx: &egui::Context) {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
|
||||||
|
let should_stay_stable = app
|
||||||
|
.menu_bar_stable_until
|
||||||
|
.map(|until| now < until)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
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(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 < 10.0;
|
||||||
|
|
||||||
|
if in_menu_trigger_area {
|
||||||
|
app.menu_bar_stable_until = Some(now + std::time::Duration::from_millis(300));
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.menu_interaction_active = false;
|
||||||
|
|
||||||
|
if should_show_menubar {
|
||||||
|
let frame = Frame::NONE.fill(ctx.style().visuals.panel_fill);
|
||||||
|
egui::TopBottomPanel::top("menubar")
|
||||||
|
.frame(frame)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
let menu_bar_rect = ui.available_rect_before_wrap();
|
||||||
|
if let Some(pointer_pos) = ctx.pointer_hover_pos() {
|
||||||
|
if menu_bar_rect.contains(pointer_pos) {
|
||||||
|
app.menu_interaction_active = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_menu();
|
||||||
|
}
|
||||||
|
if ui.button("Open...").clicked() {
|
||||||
|
io::open_file(app);
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if ui.button("Save").clicked() {
|
||||||
|
io::save_file(app);
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
if ui.button("Save As...").clicked() {
|
||||||
|
io::save_as_file(app);
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if ui.button("Preferences").clicked() {
|
||||||
|
app.show_preferences = true;
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
if ui.button("Exit").clicked() {
|
||||||
|
app.request_quit(ctx);
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.menu_button("Edit", |ui| {
|
||||||
|
app.menu_interaction_active = true;
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
if ui.button("Select All").clicked() {
|
||||||
|
let text_edit_id = egui::Id::new("main_text_editor");
|
||||||
|
if let Some(mut state) =
|
||||||
|
egui::TextEdit::load_state(ui.ctx(), text_edit_id)
|
||||||
|
{
|
||||||
|
if let Some(active_tab) = app.get_active_tab() {
|
||||||
|
let text_len = active_tab.content.len();
|
||||||
|
let select_all_range = egui::text::CCursorRange::two(
|
||||||
|
egui::text::CCursor::new(0),
|
||||||
|
egui::text::CCursor::new(text_len),
|
||||||
|
);
|
||||||
|
state.cursor.set_char_range(Some(select_all_range));
|
||||||
|
egui::TextEdit::store_state(ui.ctx(), text_edit_id, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if ui.button("Undo").clicked() {
|
||||||
|
let text_edit_id = egui::Id::new("main_text_editor");
|
||||||
|
if let Some(mut state) =
|
||||||
|
egui::TextEdit::load_state(ui.ctx(), text_edit_id)
|
||||||
|
{
|
||||||
|
if let Some(active_tab) = app.get_active_tab_mut() {
|
||||||
|
let current_state = (
|
||||||
|
state.cursor.char_range().unwrap_or_default(),
|
||||||
|
active_tab.content.clone(),
|
||||||
|
);
|
||||||
|
let mut undoer = state.undoer();
|
||||||
|
if let Some((cursor_range, content)) =
|
||||||
|
undoer.undo(¤t_state)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
if ui.button("Redo").clicked() {
|
||||||
|
let text_edit_id = egui::Id::new("main_text_editor");
|
||||||
|
if let Some(mut state) =
|
||||||
|
egui::TextEdit::load_state(ui.ctx(), text_edit_id)
|
||||||
|
{
|
||||||
|
if let Some(active_tab) = app.get_active_tab_mut() {
|
||||||
|
let current_state = (
|
||||||
|
state.cursor.char_range().unwrap_or_default(),
|
||||||
|
active_tab.content.clone(),
|
||||||
|
);
|
||||||
|
let mut undoer = state.undoer();
|
||||||
|
if let Some((cursor_range, content)) =
|
||||||
|
undoer.redo(¤t_state)
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.menu_button("View", |ui| {
|
||||||
|
app.menu_interaction_active = true;
|
||||||
|
if ui
|
||||||
|
.checkbox(&mut app.show_line_numbers, "Toggle Line Numbers")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
app.save_config();
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.checkbox(&mut app.word_wrap, "Toggle Word Wrap")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
app.save_config();
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.checkbox(&mut app.auto_hide_toolbar, "Auto Hide Toolbar")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
app.save_config();
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
if ui.button("Reset Zoom").clicked() {
|
||||||
|
app.zoom_factor = 1.0;
|
||||||
|
ctx.set_zoom_factor(1.0);
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
ui.menu_button("Appearance", |ui| {
|
||||||
|
app.menu_interaction_active = true;
|
||||||
|
let current_theme = app.theme;
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.radio_value(
|
||||||
|
&mut app.theme,
|
||||||
|
crate::app::theme::Theme::System,
|
||||||
|
"System",
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
if current_theme != crate::app::theme::Theme::System {
|
||||||
|
app.set_theme(ctx);
|
||||||
|
}
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.radio_value(
|
||||||
|
&mut app.theme,
|
||||||
|
crate::app::theme::Theme::Light,
|
||||||
|
"Light",
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
if current_theme != crate::app::theme::Theme::Light {
|
||||||
|
app.set_theme(ctx);
|
||||||
|
}
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.radio_value(&mut app.theme, crate::app::theme::Theme::Dark, "Dark")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
if current_theme != crate::app::theme::Theme::Dark {
|
||||||
|
app.set_theme(ctx);
|
||||||
|
}
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if ui.radio_value(&mut app.line_side, false, "Left").clicked() {
|
||||||
|
app.save_config();
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
if ui.radio_value(&mut app.line_side, true, "Right").clicked() {
|
||||||
|
app.save_config();
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.menu_button("Help", |ui| {
|
||||||
|
app.menu_interaction_active = true;
|
||||||
|
if ui.button("Shortcuts").clicked() {
|
||||||
|
app.show_shortcuts = true;
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
if ui.button("About").clicked() {
|
||||||
|
app.show_about = true;
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
149
src/ui/preferences_window.rs
Normal file
149
src/ui/preferences_window.rs
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
use crate::app::TextEditor;
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) {
|
||||||
|
let visuals = &ctx.style().visuals;
|
||||||
|
let screen_rect = ctx.screen_rect();
|
||||||
|
let window_width = (screen_rect.width() * 0.6).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")
|
||||||
|
.collapsible(false)
|
||||||
|
.resizable(false)
|
||||||
|
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
|
||||||
|
.default_open(true)
|
||||||
|
.max_size(max_size)
|
||||||
|
.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.heading("Font Settings");
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Font Family:");
|
||||||
|
ui.add_space(5.0);
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
egui::ComboBox::from_id_salt("font_family")
|
||||||
|
.selected_text(&app.font_family)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
if ui
|
||||||
|
.selectable_value(
|
||||||
|
&mut app.font_family,
|
||||||
|
"Proportional".to_string(),
|
||||||
|
"Proportional",
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.selectable_value(
|
||||||
|
&mut app.font_family,
|
||||||
|
"Monospace".to_string(),
|
||||||
|
"Monospace",
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if 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().clone();
|
||||||
|
let response = ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut font_size_text)
|
||||||
|
.desired_width(50.0)
|
||||||
|
.hint_text("14")
|
||||||
|
.id(egui::Id::new("font_size_input")),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.font_size_input = Some(font_size_text.clone());
|
||||||
|
|
||||||
|
if response.clicked() {
|
||||||
|
response.request_focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.label("px");
|
||||||
|
|
||||||
|
if response.lost_focus() {
|
||||||
|
if let Ok(new_size) = font_size_text.parse::<f32>() {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.font_size_input = None;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label("Preview:");
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.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(8))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
let preview_font = egui::FontId::new(
|
||||||
|
app.font_size,
|
||||||
|
match app.font_family.as_str() {
|
||||||
|
"Monospace" => egui::FontFamily::Monospace,
|
||||||
|
_ => egui::FontFamily::Proportional,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ui.label(
|
||||||
|
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.clone()),
|
||||||
|
);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("abcdefghijklmnopqrstuvwxyz")
|
||||||
|
.font(preview_font.clone()),
|
||||||
|
);
|
||||||
|
ui.label(egui::RichText::new("1234567890 !@#$%^&*()").font(preview_font));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
|
||||||
|
if ui.button("Close").clicked() {
|
||||||
|
app.show_preferences = false;
|
||||||
|
app.font_size_input = None;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
102
src/ui/shortcuts_window.rs
Normal file
102
src/ui/shortcuts_window.rs
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
use crate::app::TextEditor;
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
fn render_shortcuts_content(ui: &mut egui::Ui) {
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.label(egui::RichText::new("Navigation").size(18.0).strong());
|
||||||
|
ui.label(egui::RichText::new("Ctrl + N: New").size(14.0));
|
||||||
|
ui.label(egui::RichText::new("Ctrl + O: Open").size(14.0));
|
||||||
|
ui.label(egui::RichText::new("Ctrl + S: Save").size(14.0));
|
||||||
|
ui.label(egui::RichText::new("Ctrl + Shift + S: Save As").size(14.0));
|
||||||
|
ui.label(egui::RichText::new("Ctrl + T: New Tab").size(14.0));
|
||||||
|
ui.label(egui::RichText::new("Ctrl + Tab: Next Tab").size(14.0));
|
||||||
|
ui.label(egui::RichText::new("Ctrl + Shift + Tab: Last Tab").size(14.0));
|
||||||
|
ui.add_space(16.0);
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
ui.label(egui::RichText::new("Editing").size(18.0).strong());
|
||||||
|
ui.label(egui::RichText::new("Ctrl + Z: Undo").size(14.0));
|
||||||
|
ui.label(egui::RichText::new("Ctrl + Shift + Z: Redo").size(14.0));
|
||||||
|
ui.label(egui::RichText::new("Ctrl + X: Cut").size(14.0));
|
||||||
|
ui.label(egui::RichText::new("Ctrl + C: Copy").size(14.0));
|
||||||
|
ui.label(egui::RichText::new("Ctrl + V: Paste").size(14.0));
|
||||||
|
ui.label(egui::RichText::new("Ctrl + A: Select All").size(14.0));
|
||||||
|
ui.label(egui::RichText::new("Ctrl + D: Delete Line").size(14.0));
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.label(egui::RichText::new("Views").size(18.0).strong());
|
||||||
|
ui.label(egui::RichText::new("Ctrl + L: Toggle Line Numbers").size(14.0));
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("Ctrl + Shift + L: Change Line Number Side").size(14.0),
|
||||||
|
);
|
||||||
|
ui.label(egui::RichText::new("Ctrl + K: Toggle Word Wrap").size(14.0));
|
||||||
|
ui.label(egui::RichText::new("Ctrl + H: Toggle Auto Hide Toolbar").size(14.0));
|
||||||
|
ui.label(egui::RichText::new("Ctrl + P: Preferences").size(14.0));
|
||||||
|
ui.label(egui::RichText::new("Ctrl + =/-: Increase/Decrease Font Size").size(14.0));
|
||||||
|
ui.label(egui::RichText::new("Ctrl + Shift + =/-: Zoom In/Out").size(14.0));
|
||||||
|
|
||||||
|
// ui.label(
|
||||||
|
// egui::RichText::new("Ctrl + Shift + .: Toggle Vim Mode")
|
||||||
|
// .size(14.0)
|
||||||
|
// );
|
||||||
|
// ui.label(
|
||||||
|
// egui::RichText::new("Ctrl + .: Toggle Vim Mode")
|
||||||
|
// .size(14.0)
|
||||||
|
// );
|
||||||
|
ui.add_space(12.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn shortcuts_window(app: &mut TextEditor, ctx: &egui::Context) {
|
||||||
|
let visuals = &ctx.style().visuals;
|
||||||
|
let screen_rect = ctx.screen_rect();
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
.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(|ui| {
|
||||||
|
// 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),
|
||||||
|
|ui| {
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.auto_shrink([false; 2])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
render_shortcuts_content(ui);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fixed close button at bottom
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
let visuals = ui.visuals();
|
||||||
|
let close_button = egui::Button::new("Close")
|
||||||
|
.fill(visuals.widgets.inactive.bg_fill)
|
||||||
|
.stroke(visuals.widgets.inactive.bg_stroke);
|
||||||
|
|
||||||
|
if ui.add(close_button).clicked() {
|
||||||
|
app.show_shortcuts = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
88
src/ui/tab_bar.rs
Normal file
88
src/ui/tab_bar.rs
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
use crate::app::{state::UnsavedAction, TextEditor};
|
||||||
|
use eframe::egui::{self, Frame};
|
||||||
|
|
||||||
|
pub(crate) fn tab_bar(app: &mut TextEditor, ctx: &egui::Context) {
|
||||||
|
let frame = Frame::NONE.fill(ctx.style().visuals.panel_fill);
|
||||||
|
let response = egui::TopBottomPanel::top("tab_bar")
|
||||||
|
.frame(frame)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let mut tab_to_close_unmodified = None;
|
||||||
|
let mut tab_to_close_modified = None;
|
||||||
|
let mut tab_to_switch = None;
|
||||||
|
let mut add_new_tab = false;
|
||||||
|
|
||||||
|
let tabs_len = app.tabs.len();
|
||||||
|
let active_tab_index = app.active_tab_index;
|
||||||
|
|
||||||
|
let tabs_info: Vec<(String, bool)> = app
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.map(|tab| (tab.get_display_title(), tab.is_modified))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (i, (title, is_modified)) in tabs_info.iter().enumerate() {
|
||||||
|
let is_active = i == active_tab_index;
|
||||||
|
|
||||||
|
let mut label_text = if is_active {
|
||||||
|
egui::RichText::new(title).strong()
|
||||||
|
} else {
|
||||||
|
egui::RichText::new(title).color(ui.visuals().weak_text_color())
|
||||||
|
};
|
||||||
|
|
||||||
|
if *is_modified {
|
||||||
|
label_text = label_text.italics();
|
||||||
|
}
|
||||||
|
|
||||||
|
let tab_response =
|
||||||
|
ui.add(egui::Label::new(label_text).sense(egui::Sense::click()));
|
||||||
|
if tab_response.clicked() {
|
||||||
|
tab_to_switch = Some(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if tabs_len > 1 {
|
||||||
|
let visuals = ui.visuals();
|
||||||
|
let close_button = egui::Button::new("×")
|
||||||
|
.small()
|
||||||
|
.fill(visuals.panel_fill)
|
||||||
|
.stroke(egui::Stroke::new(0.0, egui::Color32::from_rgb(0, 0, 0)));
|
||||||
|
let close_response = ui.add(close_button);
|
||||||
|
if close_response.clicked() {
|
||||||
|
if *is_modified {
|
||||||
|
tab_to_close_modified = Some(i);
|
||||||
|
} else {
|
||||||
|
tab_to_close_unmodified = Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
}
|
||||||
|
|
||||||
|
let visuals = ui.visuals();
|
||||||
|
let add_button = egui::Button::new("+")
|
||||||
|
.small()
|
||||||
|
.fill(visuals.panel_fill)
|
||||||
|
.stroke(egui::Stroke::new(0.0, egui::Color32::from_rgb(0, 0, 0)));
|
||||||
|
if ui.add(add_button).clicked() {
|
||||||
|
add_new_tab = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tab_index) = tab_to_switch {
|
||||||
|
app.switch_to_tab(tab_index);
|
||||||
|
}
|
||||||
|
if let Some(tab_index) = tab_to_close_unmodified {
|
||||||
|
app.close_tab(tab_index);
|
||||||
|
}
|
||||||
|
if let Some(tab_index) = tab_to_close_modified {
|
||||||
|
app.switch_to_tab(tab_index);
|
||||||
|
app.pending_unsaved_action = Some(UnsavedAction::CloseTab(tab_index));
|
||||||
|
}
|
||||||
|
if add_new_tab {
|
||||||
|
app.add_new_tab();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.tab_bar_rect = Some(response.response.rect);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user