Initial Commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
1761
Cargo.lock
generated
Normal file
1761
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
Cargo.toml
Normal file
25
Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "TermCode"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ratatui = "0.29"
|
||||||
|
crossterm = "0.28"
|
||||||
|
tokio = { version = "1.35", features = ["full"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
toml = "0.8"
|
||||||
|
ropey = "1.6"
|
||||||
|
anyhow = "1.0"
|
||||||
|
unicode-width = "0.1"
|
||||||
|
dirs = "5.0"
|
||||||
|
path-absolutize = "3.1"
|
||||||
|
clap = { version = "4.4", features = ["derive"] }
|
||||||
|
arboard = "3.3"
|
||||||
|
chrono = "0.4"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.8"
|
||||||
|
|
||||||
|
[package.metadata.appimage]
|
||||||
|
icon = "termcode.svg"
|
||||||
446
src/app.rs
Normal file
446
src/app.rs
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
use crate::buffer::Buffer;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::editor::Editor;
|
||||||
|
use crate::input::InputMode;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::ui::UI;
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use ratatui::{backend::Backend, Terminal};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
state: Arc<RwLock<AppState>>,
|
||||||
|
editor: Editor,
|
||||||
|
ui: UI,
|
||||||
|
_config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub async fn new(files: Vec<PathBuf>, config_path: Option<PathBuf>) -> Result<Self> {
|
||||||
|
let config = if let Some(path) = config_path.clone() {
|
||||||
|
Config::load(Some(path))?
|
||||||
|
} else {
|
||||||
|
let default_path = Config::default_path();
|
||||||
|
if !default_path.exists() {
|
||||||
|
let default_config = Config::default();
|
||||||
|
default_config.save(None)?; // Create default config file
|
||||||
|
println!("Created default config at: {}", default_path.display());
|
||||||
|
}
|
||||||
|
Config::load(None)?
|
||||||
|
};
|
||||||
|
let state = Arc::new(RwLock::new(AppState::new()));
|
||||||
|
|
||||||
|
let editor = Editor::new(state.clone(), config.clone()).await?;
|
||||||
|
|
||||||
|
let mut app = App {
|
||||||
|
state: state.clone(),
|
||||||
|
editor,
|
||||||
|
ui: UI::new(),
|
||||||
|
_config: config,
|
||||||
|
};
|
||||||
|
|
||||||
|
for file_path in files {
|
||||||
|
if let Err(e) = app.open_file(file_path).await {
|
||||||
|
eprintln!("Failed to open file: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn open_file(&mut self, path: PathBuf) -> Result<()> {
|
||||||
|
let buffer = Buffer::from_path(&path).await?;
|
||||||
|
self.editor.add_buffer(buffer).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Process tick for message timeouts
|
||||||
|
{
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.tick_message();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync cursor from current buffer to state before drawing
|
||||||
|
self.sync_cursor_to_state().await;
|
||||||
|
|
||||||
|
let state = (*self.state.read().await).clone();
|
||||||
|
let editor_ref = &self.editor;
|
||||||
|
let ui_ref = &mut self.ui;
|
||||||
|
|
||||||
|
terminal.draw(|f| ui_ref.draw(f, editor_ref, &state))?;
|
||||||
|
|
||||||
|
// Poll for events with a small timeout
|
||||||
|
if event::poll(Duration::from_millis(100))? {
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
if self.handle_key_event(key).await? {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_cursor_to_state(&mut self) {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer() {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_cursor_position(buffer.cursor_line, buffer.cursor_col);
|
||||||
|
state.set_scroll_position(buffer.scroll_line, buffer.scroll_col);
|
||||||
|
state.set_file_modified(buffer.modified);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_key_event(&mut self, key: KeyEvent) -> Result<bool> {
|
||||||
|
let input_mode = {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
state.input_mode
|
||||||
|
};
|
||||||
|
|
||||||
|
match input_mode {
|
||||||
|
InputMode::Normal => self.handle_normal_mode(key).await,
|
||||||
|
InputMode::Insert => self.handle_insert_mode(key).await,
|
||||||
|
InputMode::Command => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
self.ui.handle_key(key, &mut state);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
InputMode::GotoLine => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
if self.ui.handle_key(key, &mut state) {
|
||||||
|
drop(state);
|
||||||
|
self.sync_state_to_buffer().await;
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
InputMode::CommandPalette => self.handle_command_palette(key).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_state_to_buffer(&mut self) {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.cursor_line = state.cursor_line;
|
||||||
|
buffer.cursor_col = state.cursor_col;
|
||||||
|
buffer.scroll_line = state.scroll_line;
|
||||||
|
buffer.scroll_col = state.scroll_col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_normal_mode(&mut self, key: KeyEvent) -> Result<bool> {
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(KeyCode::Char('q'), KeyModifiers::CONTROL) => return Ok(true),
|
||||||
|
(KeyCode::Char('s'), KeyModifiers::CONTROL) => {
|
||||||
|
self.editor.save_current_buffer().await?;
|
||||||
|
}
|
||||||
|
(KeyCode::Char('o'), KeyModifiers::CONTROL) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_input_mode(InputMode::Command);
|
||||||
|
self.ui.command_buffer = "open ".to_string();
|
||||||
|
}
|
||||||
|
(KeyCode::Char('w'), KeyModifiers::CONTROL) => {
|
||||||
|
self.editor.close_current_buffer().await?;
|
||||||
|
}
|
||||||
|
(KeyCode::Char('p'), KeyModifiers::CONTROL) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_input_mode(InputMode::CommandPalette);
|
||||||
|
self.ui.command_palette_selected = 0;
|
||||||
|
}
|
||||||
|
(KeyCode::Char('b'), KeyModifiers::CONTROL) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.toggle_sidebar();
|
||||||
|
}
|
||||||
|
// Insert mode
|
||||||
|
(KeyCode::Char('i'), KeyModifiers::NONE) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_input_mode(InputMode::Insert);
|
||||||
|
state.set_message("-- INSERT --".to_string());
|
||||||
|
}
|
||||||
|
// Append mode
|
||||||
|
(KeyCode::Char('a'), KeyModifiers::NONE) => {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.move_cursor(0, 1);
|
||||||
|
}
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_input_mode(InputMode::Insert);
|
||||||
|
state.set_message("-- INSERT --".to_string());
|
||||||
|
}
|
||||||
|
// Append at end of line
|
||||||
|
(KeyCode::Char('A'), KeyModifiers::NONE) => {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.move_to_end_of_line();
|
||||||
|
}
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_input_mode(InputMode::Insert);
|
||||||
|
state.set_message("-- INSERT --".to_string());
|
||||||
|
}
|
||||||
|
// Open line below
|
||||||
|
(KeyCode::Char('o'), KeyModifiers::NONE) => {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.move_to_end_of_line();
|
||||||
|
buffer.insert_newline();
|
||||||
|
}
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_input_mode(InputMode::Insert);
|
||||||
|
state.set_message("-- INSERT --".to_string());
|
||||||
|
}
|
||||||
|
// Open line above
|
||||||
|
(KeyCode::Char('O'), KeyModifiers::NONE) => {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.move_to_start_of_line();
|
||||||
|
buffer.insert_newline();
|
||||||
|
buffer.move_cursor(-1, 0);
|
||||||
|
}
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_input_mode(InputMode::Insert);
|
||||||
|
state.set_message("-- INSERT --".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
(KeyCode::Tab, _) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
if state.sidebar_visible {
|
||||||
|
let new_focus = !state.sidebar_focus;
|
||||||
|
state.set_sidebar_focus(new_focus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Char('g'), KeyModifiers::CONTROL) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_input_mode(InputMode::GotoLine);
|
||||||
|
self.ui.goto_buffer.clear();
|
||||||
|
}
|
||||||
|
(KeyCode::Char('n'), KeyModifiers::CONTROL) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_message("New file - use :e <filename>".to_string());
|
||||||
|
}
|
||||||
|
(KeyCode::Char(','), KeyModifiers::ALT) => {
|
||||||
|
self.editor.prev_buffer();
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_message("Previous buffer".to_string());
|
||||||
|
}
|
||||||
|
(KeyCode::Char('.'), KeyModifiers::ALT) => {
|
||||||
|
self.editor.next_buffer();
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_message("Next buffer".to_string());
|
||||||
|
}
|
||||||
|
(KeyCode::Enter, _) => {
|
||||||
|
let sidebar_focus = {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
state.sidebar_focus
|
||||||
|
};
|
||||||
|
|
||||||
|
if sidebar_focus {
|
||||||
|
if self.ui.is_selected_go_up() {
|
||||||
|
if let Some(parent) = std::env::current_dir()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
|
||||||
|
{
|
||||||
|
if let Err(e) = std::env::set_current_dir(&parent) {
|
||||||
|
eprintln!("Failed to change directory: {:?}", e);
|
||||||
|
}
|
||||||
|
self.ui.refresh_files();
|
||||||
|
self.ui.sidebar_selected = 0;
|
||||||
|
}
|
||||||
|
} else if let Some(file_item) = self.ui.get_selected_file().cloned() {
|
||||||
|
if file_item.is_dir {
|
||||||
|
self.ui.toggle_folder_expand(self.ui.sidebar_selected);
|
||||||
|
} else {
|
||||||
|
let _ = self.open_file(file_item.path).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Enter key in editor - insert newline
|
||||||
|
self.editor.handle_key(key).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let sidebar_focus = {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
state.sidebar_focus
|
||||||
|
};
|
||||||
|
|
||||||
|
if sidebar_focus {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up => self.ui.sidebar_up(),
|
||||||
|
KeyCode::Down => self.ui.sidebar_down(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.editor.handle_key(key).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_insert_mode(&mut self, key: KeyEvent) -> Result<bool> {
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(KeyCode::Esc, _) => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_input_mode(InputMode::Normal);
|
||||||
|
state.set_message("".to_string());
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.move_cursor(0, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Backspace, _) => {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.delete_backward();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Delete, _) => {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.delete_forward();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Enter, _) => {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.insert_newline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Tab, _) => {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.insert_tab();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Up, _) => {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.move_cursor(-1, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Down, _) => {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.move_cursor(1, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Left, _) => {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.move_cursor(0, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Right, _) => {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.move_cursor(0, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Home, _) => {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.move_to_start_of_line();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::End, _) => {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.move_to_end_of_line();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Char('z'), KeyModifiers::CONTROL) => {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.undo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Char('y'), KeyModifiers::CONTROL) => {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.redo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Char('v'), KeyModifiers::CONTROL) => {
|
||||||
|
let paste_text = self.editor.get_yank_buffer().cloned().unwrap_or_default();
|
||||||
|
if !paste_text.is_empty() {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.insert_string(&paste_text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Char(ch), KeyModifiers::NONE) => {
|
||||||
|
if !ch.is_control() {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.insert_char(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Char(ch), KeyModifiers::ALT | KeyModifiers::CONTROL) => {
|
||||||
|
if !ch.is_control() {
|
||||||
|
if let Some(buffer) = self.editor.current_buffer_mut() {
|
||||||
|
buffer.insert_char(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_command_palette(&mut self, key: KeyEvent) -> Result<bool> {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_input_mode(InputMode::Normal);
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
if !self.ui.command_palette_items.is_empty() {
|
||||||
|
self.ui.command_palette_selected =
|
||||||
|
self.ui.command_palette_selected.saturating_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if !self.ui.command_palette_items.is_empty() {
|
||||||
|
let max_index = self.ui.command_palette_items.len().saturating_sub(1);
|
||||||
|
self.ui.command_palette_selected =
|
||||||
|
(self.ui.command_palette_selected + 1).min(max_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if let Some(cmd) = self.ui.get_selected_command().cloned() {
|
||||||
|
let should_quit = self.execute_command(&cmd).await?;
|
||||||
|
if should_quit {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_input_mode(InputMode::Normal);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_command(&mut self, command: &str) -> Result<bool> {
|
||||||
|
if command.starts_with("Save File") {
|
||||||
|
self.editor.save_current_buffer().await?;
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_message("File saved".to_string());
|
||||||
|
} else if command.starts_with("Close File") {
|
||||||
|
self.editor.close_current_buffer().await?;
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_message("File closed".to_string());
|
||||||
|
} else if command.starts_with("Toggle Sidebar") {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.toggle_sidebar();
|
||||||
|
state.set_message("Sidebar toggled".to_string());
|
||||||
|
} else if command.starts_with("Go to Line") {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_input_mode(InputMode::GotoLine);
|
||||||
|
self.ui.goto_buffer.clear();
|
||||||
|
state.set_message("Enter line number".to_string());
|
||||||
|
} else if command.starts_with("Next Buffer") {
|
||||||
|
self.editor.next_buffer();
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_message("Next buffer".to_string());
|
||||||
|
} else if command.starts_with("Previous Buffer") {
|
||||||
|
self.editor.prev_buffer();
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_message("Previous buffer".to_string());
|
||||||
|
} else if command.starts_with("Quit") {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
688
src/buffer.rs
Normal file
688
src/buffer.rs
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use ropey::Rope;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum EditAction {
|
||||||
|
Insert {
|
||||||
|
position: usize,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
Delete {
|
||||||
|
position: usize,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
Replace {
|
||||||
|
position: usize,
|
||||||
|
old_text: String,
|
||||||
|
new_text: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EditAction {
|
||||||
|
pub fn invert(&self) -> EditAction {
|
||||||
|
match self {
|
||||||
|
EditAction::Insert { position, text } => EditAction::Delete {
|
||||||
|
position: *position,
|
||||||
|
text: text.clone(),
|
||||||
|
},
|
||||||
|
EditAction::Delete { position, text } => EditAction::Insert {
|
||||||
|
position: *position,
|
||||||
|
text: text.clone(),
|
||||||
|
},
|
||||||
|
EditAction::Replace {
|
||||||
|
position,
|
||||||
|
old_text,
|
||||||
|
new_text,
|
||||||
|
} => EditAction::Replace {
|
||||||
|
position: *position,
|
||||||
|
old_text: new_text.clone(),
|
||||||
|
new_text: old_text.clone(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EditHistory {
|
||||||
|
undo_stack: Vec<EditAction>,
|
||||||
|
redo_stack: Vec<EditAction>,
|
||||||
|
max_history: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EditHistory {
|
||||||
|
pub fn new(max_history: usize) -> Self {
|
||||||
|
EditHistory {
|
||||||
|
undo_stack: Vec::with_capacity(max_history),
|
||||||
|
redo_stack: Vec::with_capacity(max_history),
|
||||||
|
max_history,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, action: EditAction) {
|
||||||
|
self.undo_stack.push(action);
|
||||||
|
self.redo_stack.clear();
|
||||||
|
if self.undo_stack.len() > self.max_history {
|
||||||
|
self.undo_stack.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn undo(&mut self, rope: &mut Rope) -> Option<EditAction> {
|
||||||
|
if let Some(action) = self.undo_stack.pop() {
|
||||||
|
let inverted = action.invert();
|
||||||
|
Self::apply_action(rope, &inverted);
|
||||||
|
self.redo_stack.push(action.clone());
|
||||||
|
Some(action)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redo(&mut self, rope: &mut Rope) -> Option<EditAction> {
|
||||||
|
if let Some(action) = self.redo_stack.pop() {
|
||||||
|
let inverted = action.invert();
|
||||||
|
Self::apply_action(rope, &inverted);
|
||||||
|
self.undo_stack.push(action.clone());
|
||||||
|
Some(action)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_action(rope: &mut Rope, action: &EditAction) {
|
||||||
|
match action {
|
||||||
|
EditAction::Insert { position, text } => {
|
||||||
|
rope.insert(*position, text);
|
||||||
|
}
|
||||||
|
EditAction::Delete { position, text } => {
|
||||||
|
let char_count = text.chars().count();
|
||||||
|
if char_count > 0 {
|
||||||
|
rope.remove(*position..*position + char_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditAction::Replace {
|
||||||
|
position,
|
||||||
|
old_text,
|
||||||
|
new_text,
|
||||||
|
} => {
|
||||||
|
let old_len = old_text.chars().count();
|
||||||
|
if old_len > 0 {
|
||||||
|
rope.remove(*position..*position + old_len);
|
||||||
|
}
|
||||||
|
if !new_text.is_empty() {
|
||||||
|
rope.insert(*position, new_text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_undo(&self) -> bool {
|
||||||
|
!self.undo_stack.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_redo(&self) -> bool {
|
||||||
|
!self.redo_stack.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.undo_stack.clear();
|
||||||
|
self.redo_stack.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Buffer {
|
||||||
|
pub rope: Rope,
|
||||||
|
pub path: Option<PathBuf>,
|
||||||
|
pub modified: bool,
|
||||||
|
pub cursor_line: usize,
|
||||||
|
pub cursor_col: usize,
|
||||||
|
pub scroll_line: usize,
|
||||||
|
pub scroll_col: usize,
|
||||||
|
pub history: EditHistory,
|
||||||
|
pub last_save_revision: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Buffer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Buffer {
|
||||||
|
rope: Rope::new(),
|
||||||
|
path: None,
|
||||||
|
modified: false,
|
||||||
|
cursor_line: 0,
|
||||||
|
cursor_col: 0,
|
||||||
|
scroll_line: 0,
|
||||||
|
scroll_col: 0,
|
||||||
|
history: EditHistory::new(500),
|
||||||
|
last_save_revision: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn from_path(path: &PathBuf) -> Result<Self> {
|
||||||
|
let content = tokio::fs::read_to_string(path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to read file: {}", path.display()))?;
|
||||||
|
|
||||||
|
let rope = Rope::from(&content as &str);
|
||||||
|
|
||||||
|
Ok(Buffer {
|
||||||
|
rope,
|
||||||
|
path: Some(path.clone()),
|
||||||
|
modified: false,
|
||||||
|
cursor_line: 0,
|
||||||
|
cursor_col: 0,
|
||||||
|
scroll_line: 0,
|
||||||
|
scroll_col: 0,
|
||||||
|
history: EditHistory::new(500),
|
||||||
|
last_save_revision: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&mut self) -> Result<()> {
|
||||||
|
if let Some(ref path) = self.path {
|
||||||
|
let content = self.rope.to_string();
|
||||||
|
std::fs::write(path, content)
|
||||||
|
.with_context(|| format!("Failed to write file: {}", path.display()))?;
|
||||||
|
self.modified = false;
|
||||||
|
self.last_save_revision = self.rope.len_chars();
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!("No file path set for buffer"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_modified(&self) -> bool {
|
||||||
|
self.modified
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn line_count(&self) -> usize {
|
||||||
|
self.rope.len_lines()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_line(&self, line: usize) -> Option<String> {
|
||||||
|
if line < self.line_count() {
|
||||||
|
Some(self.rope.line(line).to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn line_to_char(&self, line: usize) -> usize {
|
||||||
|
self.rope.line_to_char(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn char_to_line(&self, char_idx: usize) -> usize {
|
||||||
|
self.rope.char_to_line(char_idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_cursor(&mut self, line_delta: i32, col_delta: i32) {
|
||||||
|
let new_line = (self.cursor_line as i32 + line_delta).max(0) as usize;
|
||||||
|
let line_count = self.line_count();
|
||||||
|
self.cursor_line = new_line.min(line_count.saturating_sub(1));
|
||||||
|
|
||||||
|
let line_content = self.rope.line(self.cursor_line).to_string();
|
||||||
|
let line_len = line_content.trim_end_matches('\n').len();
|
||||||
|
|
||||||
|
if line_delta != 0 {
|
||||||
|
self.cursor_col = self.cursor_col.min(line_len);
|
||||||
|
} else {
|
||||||
|
let new_col = (self.cursor_col as i32 + col_delta).max(0) as usize;
|
||||||
|
self.cursor_col = new_col.min(line_len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_to_start_of_line(&mut self) {
|
||||||
|
self.cursor_col = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_to_end_of_line(&mut self) {
|
||||||
|
let line_content = self.rope.line(self.cursor_line).to_string();
|
||||||
|
self.cursor_col = line_content.trim_end_matches('\n').len();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll(&mut self, delta: i32) {
|
||||||
|
let new_scroll = (self.scroll_line as i32 + delta).max(0) as usize;
|
||||||
|
self.scroll_line = new_scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_cursor_to(&mut self, line: usize, col: usize) {
|
||||||
|
let line_count = self.line_count();
|
||||||
|
self.cursor_line = line.min(line_count.saturating_sub(1));
|
||||||
|
let line_content = self.rope.line(self.cursor_line).to_string();
|
||||||
|
let line_len = line_content.trim_end_matches('\n').len();
|
||||||
|
self.cursor_col = col.min(line_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_to_line_start(&mut self) {
|
||||||
|
self.cursor_col = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_to_line_end(&mut self) {
|
||||||
|
let line_content = self.rope.line(self.cursor_line).to_string();
|
||||||
|
self.cursor_col = line_content.trim_end_matches('\n').len();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_word_forward(&mut self) {
|
||||||
|
let char_idx = self.line_to_char(self.cursor_line) + self.cursor_col;
|
||||||
|
let text_after: String = self
|
||||||
|
.rope
|
||||||
|
.get_slice(char_idx..)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let text_len = text_after.len();
|
||||||
|
|
||||||
|
let mut new_pos = char_idx;
|
||||||
|
let mut found_non_word = false;
|
||||||
|
|
||||||
|
for (idx, ch) in text_after.char_indices() {
|
||||||
|
if ch.is_alphanumeric() || ch == '_' {
|
||||||
|
if found_non_word {
|
||||||
|
new_pos = char_idx + idx;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
found_non_word = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_pos < char_idx + text_len {
|
||||||
|
self.cursor_line = self.char_to_line(new_pos);
|
||||||
|
self.cursor_col = new_pos - self.line_to_char(self.cursor_line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_word_backward(&mut self) {
|
||||||
|
let char_idx = self.line_to_char(self.cursor_line) + self.cursor_col;
|
||||||
|
if char_idx == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text_before: String = self
|
||||||
|
.rope
|
||||||
|
.get_slice(0..char_idx)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut new_pos = 0;
|
||||||
|
let mut in_word = false;
|
||||||
|
|
||||||
|
for (idx, ch) in text_before.char_indices() {
|
||||||
|
if ch.is_alphanumeric() || ch == '_' {
|
||||||
|
in_word = true;
|
||||||
|
} else if in_word {
|
||||||
|
new_pos = idx;
|
||||||
|
in_word = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cursor_line = self.char_to_line(new_pos);
|
||||||
|
self.cursor_col = new_pos - self.line_to_char(self.cursor_line);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_position(&self) -> usize {
|
||||||
|
self.line_to_char(self.cursor_line) + self.cursor_col
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_char(&mut self, ch: char) {
|
||||||
|
let pos = self.cursor_position();
|
||||||
|
self.rope.insert(pos, &ch.to_string());
|
||||||
|
self.move_cursor(0, 1);
|
||||||
|
self.modified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_string(&mut self, text: &str) {
|
||||||
|
if text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pos = self.cursor_position();
|
||||||
|
self.rope.insert(pos, text);
|
||||||
|
let char_count = text.chars().count();
|
||||||
|
self.move_cursor(0, char_count as i32);
|
||||||
|
self.modified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_newline(&mut self) {
|
||||||
|
let pos = self.cursor_position();
|
||||||
|
self.rope.insert(pos, "\n");
|
||||||
|
self.move_cursor(1, 0);
|
||||||
|
self.move_to_line_start();
|
||||||
|
self.modified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_tab(&mut self) {
|
||||||
|
self.insert_string(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_backward(&mut self) {
|
||||||
|
if self.cursor_col > 0 {
|
||||||
|
let pos = self.cursor_position();
|
||||||
|
let char_idx = pos - 1;
|
||||||
|
self.rope.remove(char_idx..pos);
|
||||||
|
self.move_cursor(0, -1);
|
||||||
|
self.modified = true;
|
||||||
|
} else if self.cursor_line > 0 {
|
||||||
|
let current_line_start = self.line_to_char(self.cursor_line);
|
||||||
|
let prev_line_end = self.line_to_char(self.cursor_line - 1);
|
||||||
|
|
||||||
|
if current_line_start > prev_line_end {
|
||||||
|
self.rope.remove(prev_line_end..current_line_start);
|
||||||
|
self.move_cursor(-1, 0);
|
||||||
|
let new_line_count = self.line_count();
|
||||||
|
if self.cursor_line < new_line_count {
|
||||||
|
let prev_line = self.rope.line(self.cursor_line).to_string();
|
||||||
|
self.cursor_col = prev_line.trim_end_matches('\n').len();
|
||||||
|
} else {
|
||||||
|
self.cursor_col = 0;
|
||||||
|
}
|
||||||
|
self.modified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_forward(&mut self) {
|
||||||
|
let pos = self.cursor_position();
|
||||||
|
let line_count = self.line_count();
|
||||||
|
let line_len = self.rope.line(self.cursor_line).len_chars();
|
||||||
|
|
||||||
|
if self.cursor_col == 0 && self.cursor_line > 0 {
|
||||||
|
let current_line_start = self.line_to_char(self.cursor_line);
|
||||||
|
if current_line_start > 0 {
|
||||||
|
self.rope.remove(current_line_start - 1..current_line_start);
|
||||||
|
self.cursor_line -= 1;
|
||||||
|
let prev_line = self.rope.line(self.cursor_line).to_string();
|
||||||
|
self.cursor_col = prev_line.trim_end_matches('\n').len();
|
||||||
|
self.modified = true;
|
||||||
|
}
|
||||||
|
} else if self.cursor_col == line_len - 1 && self.cursor_line < line_count - 1 {
|
||||||
|
self.rope.remove(pos..pos + 1);
|
||||||
|
self.modified = true;
|
||||||
|
} else if self.cursor_col < line_len - 1 {
|
||||||
|
self.rope.remove(pos..pos + 1);
|
||||||
|
self.modified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_word_backward(&mut self) -> String {
|
||||||
|
let start_pos = self.cursor_position();
|
||||||
|
if start_pos == 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new_col = self.cursor_col;
|
||||||
|
let mut found_word = false;
|
||||||
|
let line = self.rope.line(self.cursor_line).to_string();
|
||||||
|
let line_start = self.line_to_char(self.cursor_line);
|
||||||
|
|
||||||
|
for i in (0..self.cursor_col).rev() {
|
||||||
|
let ch = line.chars().nth(i).unwrap();
|
||||||
|
if ch.is_alphanumeric() || ch == '_' {
|
||||||
|
new_col = i;
|
||||||
|
found_word = true;
|
||||||
|
} else if found_word {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let delete_start = line_start + new_col;
|
||||||
|
let deleted: String = self
|
||||||
|
.rope
|
||||||
|
.get_slice(delete_start..start_pos)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.rope.remove(delete_start..start_pos);
|
||||||
|
self.cursor_col = new_col;
|
||||||
|
self.modified = true;
|
||||||
|
deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_word_forward(&mut self) -> String {
|
||||||
|
let start_pos = self.cursor_position();
|
||||||
|
let line_count = self.line_count();
|
||||||
|
let line_start = self.line_to_char(self.cursor_line);
|
||||||
|
let line_len = self.rope.line(self.cursor_line).len_chars();
|
||||||
|
|
||||||
|
let mut delete_end = start_pos;
|
||||||
|
let mut found_word = false;
|
||||||
|
|
||||||
|
if self.cursor_col < line_len.saturating_sub(1) {
|
||||||
|
for i in self.cursor_col..line_len {
|
||||||
|
let ch = self
|
||||||
|
.rope
|
||||||
|
.line(self.cursor_line)
|
||||||
|
.chars()
|
||||||
|
.nth(i)
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_alphanumeric() || c == '_' {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
if ch {
|
||||||
|
found_word = true;
|
||||||
|
} else if found_word {
|
||||||
|
delete_end = line_start + i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found_word {
|
||||||
|
delete_end = line_start + line_len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if delete_end > start_pos {
|
||||||
|
let deleted: String = self
|
||||||
|
.rope
|
||||||
|
.get_slice(start_pos..delete_end)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.rope.remove(start_pos..delete_end);
|
||||||
|
self.modified = true;
|
||||||
|
deleted
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_current_line(&mut self) -> String {
|
||||||
|
let line_start = self.line_to_char(self.cursor_line);
|
||||||
|
let line_count = self.line_count();
|
||||||
|
let line_len = self.rope.line(self.cursor_line).len_chars();
|
||||||
|
|
||||||
|
let line_end = if self.cursor_line < line_count - 1 {
|
||||||
|
line_start + line_len
|
||||||
|
} else {
|
||||||
|
line_start + line_len.saturating_sub(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
let deleted: String = self
|
||||||
|
.rope
|
||||||
|
.get_slice(line_start..line_end)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.rope.remove(line_start..line_end);
|
||||||
|
|
||||||
|
if self.cursor_line >= self.line_count() && self.cursor_line > 0 {
|
||||||
|
self.cursor_line = self.cursor_line.saturating_sub(1);
|
||||||
|
}
|
||||||
|
self.move_to_line_end();
|
||||||
|
self.modified = true;
|
||||||
|
deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn yank(&mut self) -> String {
|
||||||
|
let pos = self.cursor_position();
|
||||||
|
self.rope
|
||||||
|
.get_slice(pos..pos)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn yank_line(&mut self) -> String {
|
||||||
|
let line_start = self.line_to_char(self.cursor_line);
|
||||||
|
let line_count = self.line_count();
|
||||||
|
let line_len = self.rope.line(self.cursor_line).len_chars();
|
||||||
|
let line_end = if self.cursor_line < line_count - 1 {
|
||||||
|
line_start + line_len
|
||||||
|
} else {
|
||||||
|
line_start + line_len.saturating_sub(1)
|
||||||
|
};
|
||||||
|
self.rope
|
||||||
|
.get_slice(line_start..line_end)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace_selection(&mut self, text: &str) {
|
||||||
|
let pos = self.cursor_position();
|
||||||
|
let end_pos = pos + text.len();
|
||||||
|
self.rope.remove(pos..end_pos);
|
||||||
|
self.rope.insert(pos, text);
|
||||||
|
self.move_cursor(0, text.chars().count() as i32);
|
||||||
|
self.modified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn join_with_next_line(&mut self) {
|
||||||
|
let line_count = self.line_count();
|
||||||
|
if self.cursor_line >= line_count - 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_line_start = self.line_to_char(self.cursor_line);
|
||||||
|
let next_line_start = self.line_to_char(self.cursor_line + 1);
|
||||||
|
|
||||||
|
self.rope.remove(next_line_start - 1..next_line_start);
|
||||||
|
self.move_to_line_end();
|
||||||
|
self.modified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn swap_chars(&mut self) {
|
||||||
|
let pos = self.cursor_position();
|
||||||
|
if pos == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chars: Vec<char> = self.rope.chars_at(pos - 1).take(2).collect();
|
||||||
|
if chars.len() < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let char1 = chars[0];
|
||||||
|
let char2 = chars[1];
|
||||||
|
|
||||||
|
self.rope.remove(pos - 1..pos + 1);
|
||||||
|
self.rope.insert(pos - 1, &format!("{}{}", char2, char1));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn undo(&mut self) -> bool {
|
||||||
|
if self.history.undo(&mut self.rope).is_some() {
|
||||||
|
let new_pos = self.cursor_position();
|
||||||
|
self.cursor_line = self.char_to_line(new_pos);
|
||||||
|
self.cursor_col = new_pos - self.line_to_char(self.cursor_line);
|
||||||
|
self.modified = self.history.undo_stack.len() > 0
|
||||||
|
|| self.rope.len_chars() != self.last_save_revision;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redo(&mut self) -> bool {
|
||||||
|
if self.history.redo(&mut self.rope).is_some() {
|
||||||
|
let new_pos = self.cursor_position();
|
||||||
|
self.cursor_line = self.char_to_line(new_pos);
|
||||||
|
self.cursor_col = new_pos - self.line_to_char(self.cursor_line);
|
||||||
|
self.modified = self.history.undo_stack.len() > 0
|
||||||
|
|| self.rope.len_chars() != self.last_save_revision;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_undo(&self) -> bool {
|
||||||
|
self.history.can_undo()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_redo(&self) -> bool {
|
||||||
|
self.history.can_redo()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_history(&mut self) {
|
||||||
|
self.history.clear();
|
||||||
|
self.modified = self.rope.len_chars() != self.last_save_revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_matching_bracket(&self) -> Option<(usize, usize)> {
|
||||||
|
let brackets = [('(', ')'), ('[', ']'), ('{', '}')];
|
||||||
|
let chars: Vec<char> = self.rope.to_string().chars().collect();
|
||||||
|
|
||||||
|
let (open_char, close_char) = if let Some(b) = brackets
|
||||||
|
.iter()
|
||||||
|
.find(|b| self.cursor_col < chars.len() && chars[self.cursor_col] == b.0)
|
||||||
|
{
|
||||||
|
(b.0, b.1)
|
||||||
|
} else if let Some(b) = brackets
|
||||||
|
.iter()
|
||||||
|
.find(|b| self.cursor_col > 0 && chars[self.cursor_col - 1] == b.1)
|
||||||
|
{
|
||||||
|
(b.0, b.1)
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_open = chars.get(self.cursor_col) == Some(&open_char);
|
||||||
|
let start_line = self.line_to_char(self.cursor_line);
|
||||||
|
let start_col = if is_open {
|
||||||
|
self.cursor_col
|
||||||
|
} else {
|
||||||
|
if self.cursor_col > 0 {
|
||||||
|
self.cursor_col - 1
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut pos = start_line + start_col;
|
||||||
|
let mut depth = 1;
|
||||||
|
let forward = is_open;
|
||||||
|
|
||||||
|
while pos < chars.len() {
|
||||||
|
let ch = chars[pos];
|
||||||
|
if forward {
|
||||||
|
if ch == open_char {
|
||||||
|
depth += 1;
|
||||||
|
} else if ch == close_char {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
let line = self.rope.char_to_line(pos);
|
||||||
|
let col = pos - self.rope.line_to_char(line);
|
||||||
|
return Some((line, col));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos += 1;
|
||||||
|
} else {
|
||||||
|
if ch == close_char {
|
||||||
|
depth += 1;
|
||||||
|
} else if ch == open_char {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
let line = self.rope.char_to_line(pos);
|
||||||
|
let col = pos - self.rope.line_to_char(line);
|
||||||
|
return Some((line, col));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pos == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pos -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Buffer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Buffer::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/config.rs
Normal file
101
src/config.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub editor: EditorConfig,
|
||||||
|
pub ui: UIConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EditorConfig {
|
||||||
|
pub word_wrap: bool,
|
||||||
|
pub line_numbers: bool,
|
||||||
|
pub relative_line_numbers: bool,
|
||||||
|
pub auto_save: bool,
|
||||||
|
pub auto_save_delay: u64,
|
||||||
|
pub scroll_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UIConfig {
|
||||||
|
pub sidebar_width: u16,
|
||||||
|
pub status_bar_height: u16,
|
||||||
|
pub command_palette_height: u16,
|
||||||
|
pub tab_bar_height: u16,
|
||||||
|
pub mouse_enabled: bool,
|
||||||
|
pub show_whitespace: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Config {
|
||||||
|
editor: EditorConfig::default(),
|
||||||
|
ui: UIConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EditorConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
EditorConfig {
|
||||||
|
word_wrap: false,
|
||||||
|
line_numbers: true,
|
||||||
|
relative_line_numbers: false,
|
||||||
|
auto_save: false,
|
||||||
|
auto_save_delay: 5000,
|
||||||
|
scroll_offset: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UIConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
UIConfig {
|
||||||
|
sidebar_width: 30,
|
||||||
|
status_bar_height: 1,
|
||||||
|
command_palette_height: 10,
|
||||||
|
tab_bar_height: 1,
|
||||||
|
mouse_enabled: true,
|
||||||
|
show_whitespace: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load(path: Option<PathBuf>) -> Result<Self> {
|
||||||
|
let config_path = path.unwrap_or_else(Self::default_path);
|
||||||
|
|
||||||
|
if !config_path.exists() {
|
||||||
|
return Ok(Config::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&config_path).context("Failed to read config file")?;
|
||||||
|
|
||||||
|
let config: Config = toml::from_str(&content).context("Failed to parse config file")?;
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_path() -> PathBuf {
|
||||||
|
dirs::config_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("termcode")
|
||||||
|
.join("config.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: Option<PathBuf>) -> Result<()> {
|
||||||
|
let config_path = path.unwrap_or_else(Self::default_path);
|
||||||
|
|
||||||
|
if let Some(parent) = config_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = toml::to_string_pretty(self)?;
|
||||||
|
fs::write(&config_path, content)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
314
src/editor.rs
Normal file
314
src/editor.rs
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
use crate::buffer::Buffer;
|
||||||
|
use crate::config::{Config, EditorConfig};
|
||||||
|
use crate::input::InputMode;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
pub struct Editor {
|
||||||
|
buffers: Vec<Buffer>,
|
||||||
|
current_buffer: Option<usize>,
|
||||||
|
state: Arc<RwLock<AppState>>,
|
||||||
|
config: EditorConfig,
|
||||||
|
yank_buffer: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Editor {
|
||||||
|
pub async fn new(state: Arc<RwLock<AppState>>, config: Config) -> Result<Self> {
|
||||||
|
Ok(Editor {
|
||||||
|
buffers: Vec::new(),
|
||||||
|
current_buffer: None,
|
||||||
|
state,
|
||||||
|
config: config.editor,
|
||||||
|
yank_buffer: String::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_buffer(&mut self, buffer: Buffer) {
|
||||||
|
self.current_buffer = Some(self.buffers.len());
|
||||||
|
self.buffers.push(buffer);
|
||||||
|
|
||||||
|
let file_name = self
|
||||||
|
.buffers
|
||||||
|
.last()
|
||||||
|
.and_then(|b| b.path.clone())
|
||||||
|
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
|
||||||
|
.unwrap_or_else(|| "Untitled".to_string());
|
||||||
|
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_current_file_name(file_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_buffer_mut(&mut self) -> Option<&mut Buffer> {
|
||||||
|
self.current_buffer
|
||||||
|
.and_then(|idx| self.buffers.get_mut(idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_buffer(&self) -> Option<&Buffer> {
|
||||||
|
self.current_buffer.and_then(|idx| self.buffers.get(idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_current_buffer(&mut self) -> Result<()> {
|
||||||
|
if let Some(buffer) = self.current_buffer_mut() {
|
||||||
|
buffer.save()?;
|
||||||
|
buffer.modified = false;
|
||||||
|
}
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_message("File saved".to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_yank_buffer(&self) -> Option<&String> {
|
||||||
|
if self.yank_buffer.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(&self.yank_buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_yank_buffer(&mut self, text: String) {
|
||||||
|
self.yank_buffer = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
|
let input_mode = {
|
||||||
|
let state = self.state.read().await;
|
||||||
|
state.input_mode
|
||||||
|
};
|
||||||
|
|
||||||
|
if input_mode != InputMode::Normal {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let page_size = self.config.scroll_offset * 2;
|
||||||
|
let mut message: Option<String> = None;
|
||||||
|
let mut input_mode_change: Option<InputMode> = None;
|
||||||
|
let mut yank_text: Option<String> = None;
|
||||||
|
let yank_buffer_clone = self.yank_buffer.clone();
|
||||||
|
|
||||||
|
if let Some(buffer) = self.current_buffer_mut() {
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(KeyCode::Left, KeyModifiers::NONE) => buffer.move_cursor(0, -1),
|
||||||
|
(KeyCode::Right, KeyModifiers::NONE) => buffer.move_cursor(0, 1),
|
||||||
|
(KeyCode::Up, KeyModifiers::NONE) => buffer.move_cursor(-1, 0),
|
||||||
|
(KeyCode::Down, KeyModifiers::NONE) => buffer.move_cursor(1, 0),
|
||||||
|
(KeyCode::Home, KeyModifiers::NONE) => buffer.move_to_start_of_line(),
|
||||||
|
(KeyCode::End, KeyModifiers::NONE) => buffer.move_to_end_of_line(),
|
||||||
|
(KeyCode::PageUp, KeyModifiers::NONE) => {
|
||||||
|
buffer.move_cursor(-(page_size as i32), 0);
|
||||||
|
buffer.scroll(-(page_size as i32));
|
||||||
|
}
|
||||||
|
(KeyCode::PageDown, KeyModifiers::NONE) => {
|
||||||
|
buffer.move_cursor(page_size as i32, 0);
|
||||||
|
buffer.scroll(page_size as i32);
|
||||||
|
}
|
||||||
|
(KeyCode::Left, KeyModifiers::ALT) => buffer.move_word_backward(),
|
||||||
|
(KeyCode::Right, KeyModifiers::ALT) => buffer.move_word_forward(),
|
||||||
|
(KeyCode::Enter, KeyModifiers::NONE) => buffer.insert_newline(),
|
||||||
|
(KeyCode::Tab, KeyModifiers::NONE) => buffer.insert_tab(),
|
||||||
|
(KeyCode::Backspace, KeyModifiers::NONE) => buffer.delete_backward(),
|
||||||
|
(KeyCode::Delete, KeyModifiers::NONE) => buffer.delete_forward(),
|
||||||
|
(KeyCode::Backspace, KeyModifiers::CONTROL) => {
|
||||||
|
buffer.delete_word_backward();
|
||||||
|
}
|
||||||
|
(KeyCode::Char('d'), KeyModifiers::ALT) => {
|
||||||
|
buffer.delete_word_forward();
|
||||||
|
}
|
||||||
|
(KeyCode::Char('k'), KeyModifiers::CONTROL) => {
|
||||||
|
let line_start = buffer.line_to_char(buffer.cursor_line);
|
||||||
|
let current_pos = buffer.cursor_position();
|
||||||
|
let line_len = buffer.rope.line(buffer.cursor_line).len_chars();
|
||||||
|
let line_end = line_start + line_len.saturating_sub(1);
|
||||||
|
if current_pos < line_end {
|
||||||
|
buffer.rope.remove(current_pos..line_end);
|
||||||
|
buffer.modified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Char('u'), KeyModifiers::CONTROL) => {
|
||||||
|
let line_start = buffer.line_to_char(buffer.cursor_line);
|
||||||
|
let current_pos = buffer.cursor_position();
|
||||||
|
if current_pos > line_start {
|
||||||
|
buffer.rope.remove(line_start..current_pos);
|
||||||
|
buffer.cursor_col = 0;
|
||||||
|
buffer.modified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Char('z'), KeyModifiers::CONTROL) => {
|
||||||
|
if buffer.undo() {
|
||||||
|
message = Some("Undo".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Char('y'), KeyModifiers::CONTROL) => {
|
||||||
|
if buffer.redo() {
|
||||||
|
message = Some("Redo".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
|
||||||
|
let yank = buffer.yank_line();
|
||||||
|
if !yank.is_empty() {
|
||||||
|
yank_text = Some(yank.clone());
|
||||||
|
if Self::copy_to_clipboard(&yank).await.is_err() {
|
||||||
|
message = Some("Copy failed".to_string());
|
||||||
|
} else {
|
||||||
|
message = Some("Copied".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Char('x'), KeyModifiers::CONTROL) => {
|
||||||
|
let yank = buffer.delete_current_line();
|
||||||
|
if !yank.is_empty() {
|
||||||
|
yank_text = Some(yank.clone());
|
||||||
|
if Self::copy_to_clipboard(&yank).await.is_err() {
|
||||||
|
message = Some("Cut failed".to_string());
|
||||||
|
} else {
|
||||||
|
message = Some("Cut".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Char('v'), KeyModifiers::CONTROL) => {
|
||||||
|
let paste_text = if !yank_buffer_clone.is_empty() {
|
||||||
|
yank_buffer_clone
|
||||||
|
} else {
|
||||||
|
match Self::paste_from_clipboard().await {
|
||||||
|
Ok(text) if !text.is_empty() => text,
|
||||||
|
_ => {
|
||||||
|
message = Some("Paste failed".to_string());
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !paste_text.is_empty() {
|
||||||
|
buffer.insert_string(&paste_text);
|
||||||
|
message = Some("Pasted".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Char('d'), KeyModifiers::CONTROL) => {
|
||||||
|
let deleted = buffer.delete_current_line();
|
||||||
|
if !deleted.is_empty() {
|
||||||
|
yank_text = Some(deleted);
|
||||||
|
message = Some("Line deleted".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Char('y'), KeyModifiers::ALT) => {
|
||||||
|
yank_text = Some(buffer.yank_line());
|
||||||
|
message = Some("Line yanked".to_string());
|
||||||
|
}
|
||||||
|
(KeyCode::Char('t'), KeyModifiers::CONTROL) => {
|
||||||
|
buffer.swap_chars();
|
||||||
|
message = Some("Characters swapped".to_string());
|
||||||
|
}
|
||||||
|
(KeyCode::Char('j'), KeyModifiers::CONTROL) => {
|
||||||
|
buffer.join_with_next_line();
|
||||||
|
}
|
||||||
|
(KeyCode::Char('a'), KeyModifiers::CONTROL) => buffer.move_to_start_of_line(),
|
||||||
|
(KeyCode::Char('e'), KeyModifiers::CONTROL) => buffer.move_to_end_of_line(),
|
||||||
|
(KeyCode::Esc, KeyModifiers::NONE) => {
|
||||||
|
input_mode_change = Some(InputMode::Normal);
|
||||||
|
}
|
||||||
|
(KeyCode::Char(ch), KeyModifiers::NONE) => {
|
||||||
|
if !ch.is_control() {
|
||||||
|
buffer.insert_char(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Char(ch), KeyModifiers::ALT | KeyModifiers::CONTROL) => {
|
||||||
|
if !ch.is_control() {
|
||||||
|
buffer.insert_char(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let visible_height = page_size;
|
||||||
|
if buffer.cursor_line < buffer.scroll_line {
|
||||||
|
buffer.scroll_line = buffer.cursor_line.saturating_sub(4);
|
||||||
|
}
|
||||||
|
if buffer.cursor_line >= buffer.scroll_line + visible_height {
|
||||||
|
buffer.scroll_line = buffer.cursor_line.saturating_sub(visible_height - 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(yank) = yank_text {
|
||||||
|
self.yank_buffer = yank;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(msg) = message {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_message(msg);
|
||||||
|
}
|
||||||
|
if let Some(mode) = input_mode_change {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_input_mode(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn copy_to_clipboard(text: &str) -> Result<()> {
|
||||||
|
let text = text.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let mut ctx = arboard::Clipboard::new()?;
|
||||||
|
ctx.set_text(text)?;
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Clipboard task failed: {:?}", e))?
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn paste_from_clipboard() -> Result<String> {
|
||||||
|
tokio::task::spawn_blocking(|| {
|
||||||
|
let mut ctx = arboard::Clipboard::new()?;
|
||||||
|
ctx.get_text()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to get clipboard: {:?}", e))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Clipboard task failed: {:?}", e))?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn close_current_buffer(&mut self) -> Result<()> {
|
||||||
|
if let Some(idx) = self.current_buffer {
|
||||||
|
self.buffers.remove(idx);
|
||||||
|
if self.buffers.is_empty() {
|
||||||
|
self.current_buffer = None;
|
||||||
|
} else if idx >= self.buffers.len() {
|
||||||
|
self.current_buffer = Some(self.buffers.len() - 1);
|
||||||
|
} else {
|
||||||
|
self.current_buffer = Some(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_name = self
|
||||||
|
.current_buffer()
|
||||||
|
.and_then(|b| b.path.clone())
|
||||||
|
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
|
||||||
|
.unwrap_or_else(|| "Untitled".to_string());
|
||||||
|
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_current_file_name(file_name);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_buffer(&mut self) {
|
||||||
|
if let Some(current) = self.current_buffer {
|
||||||
|
let next = (current + 1) % self.buffers.len();
|
||||||
|
self.current_buffer = Some(next);
|
||||||
|
} else if !self.buffers.is_empty() {
|
||||||
|
self.current_buffer = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_buffer(&mut self) {
|
||||||
|
if let Some(current) = self.current_buffer {
|
||||||
|
let prev = if current == 0 {
|
||||||
|
self.buffers.len().saturating_sub(1)
|
||||||
|
} else {
|
||||||
|
current - 1
|
||||||
|
};
|
||||||
|
self.current_buffer = Some(prev);
|
||||||
|
} else if !self.buffers.is_empty() {
|
||||||
|
self.current_buffer = Some(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/input.rs
Normal file
8
src/input.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum InputMode {
|
||||||
|
Normal,
|
||||||
|
Insert,
|
||||||
|
CommandPalette,
|
||||||
|
GotoLine,
|
||||||
|
Command,
|
||||||
|
}
|
||||||
69
src/main.rs
Normal file
69
src/main.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
mod app;
|
||||||
|
mod buffer;
|
||||||
|
mod config;
|
||||||
|
mod editor;
|
||||||
|
mod input;
|
||||||
|
mod state;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use crossterm::{
|
||||||
|
event::{DisableMouseCapture, EnableMouseCapture},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
|
use std::io;
|
||||||
|
use std::panic;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use app::App;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Args {
|
||||||
|
files: Vec<PathBuf>,
|
||||||
|
#[arg(short, long)]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
setup_panic_hook();
|
||||||
|
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let mut app = App::new(args.files, args.config).await?;
|
||||||
|
|
||||||
|
let res = app.run(&mut terminal).await;
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
if let Err(err) = res {
|
||||||
|
eprintln!("{:?}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_panic_hook() {
|
||||||
|
let original_hook = panic::take_hook();
|
||||||
|
panic::set_hook(Box::new(move |panic_info| {
|
||||||
|
disable_raw_mode().unwrap();
|
||||||
|
execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture).unwrap();
|
||||||
|
original_hook(panic_info);
|
||||||
|
}));
|
||||||
|
}
|
||||||
117
src/state.rs
Normal file
117
src/state.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
use crate::config::EditorConfig;
|
||||||
|
use crate::input::InputMode;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub input_mode: InputMode,
|
||||||
|
pub current_file_name: String,
|
||||||
|
pub current_file_path: Option<PathBuf>,
|
||||||
|
pub cursor_line: usize,
|
||||||
|
pub cursor_col: usize,
|
||||||
|
pub scroll_line: usize,
|
||||||
|
pub scroll_col: usize,
|
||||||
|
pub file_modified: bool,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub message_timeout: usize,
|
||||||
|
pub sidebar_visible: bool,
|
||||||
|
pub sidebar_focus: bool,
|
||||||
|
pub command_history: Vec<String>,
|
||||||
|
pub command_history_index: usize,
|
||||||
|
pub editor_config: EditorConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
AppState {
|
||||||
|
input_mode: InputMode::Normal,
|
||||||
|
current_file_name: String::from("Untitled"),
|
||||||
|
current_file_path: None,
|
||||||
|
cursor_line: 0,
|
||||||
|
cursor_col: 0,
|
||||||
|
scroll_line: 0,
|
||||||
|
scroll_col: 0,
|
||||||
|
file_modified: false,
|
||||||
|
message: None,
|
||||||
|
message_timeout: 0,
|
||||||
|
sidebar_visible: true,
|
||||||
|
sidebar_focus: false,
|
||||||
|
command_history: Vec::new(),
|
||||||
|
command_history_index: 0,
|
||||||
|
editor_config: EditorConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_input_mode(&mut self, mode: InputMode) {
|
||||||
|
self.input_mode = mode;
|
||||||
|
if mode != InputMode::Command {
|
||||||
|
self.command_history_index = self.command_history.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_current_file_name(&mut self, name: String) {
|
||||||
|
self.current_file_name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_cursor_position(&mut self, line: usize, col: usize) {
|
||||||
|
self.cursor_line = line;
|
||||||
|
self.cursor_col = col;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_scroll_position(&mut self, line: usize, col: usize) {
|
||||||
|
self.scroll_line = line;
|
||||||
|
self.scroll_col = col;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_file_modified(&mut self, modified: bool) {
|
||||||
|
self.file_modified = modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_message(&mut self, message: String) {
|
||||||
|
self.message = Some(message);
|
||||||
|
self.message_timeout = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tick_message(&mut self) {
|
||||||
|
if self.message_timeout > 0 {
|
||||||
|
self.message_timeout -= 1;
|
||||||
|
if self.message_timeout == 0 {
|
||||||
|
self.message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_sidebar(&mut self) {
|
||||||
|
self.sidebar_visible = !self.sidebar_visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_sidebar_focus(&mut self, focus: bool) {
|
||||||
|
self.sidebar_focus = focus;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_command_to_history(&mut self, command: String) {
|
||||||
|
self.command_history.push(command);
|
||||||
|
self.command_history_index = self.command_history.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status_string(&self) -> String {
|
||||||
|
let mode_str = match self.input_mode {
|
||||||
|
InputMode::Normal => "NORMAL",
|
||||||
|
InputMode::Insert => "INSERT",
|
||||||
|
InputMode::CommandPalette => "CMD",
|
||||||
|
InputMode::GotoLine => "GOTO",
|
||||||
|
InputMode::Command => ":",
|
||||||
|
};
|
||||||
|
|
||||||
|
let modified = if self.file_modified { "[+]" } else { "" };
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"{} {}{} | {}:{}",
|
||||||
|
self.current_file_name,
|
||||||
|
modified,
|
||||||
|
mode_str,
|
||||||
|
self.cursor_line + 1,
|
||||||
|
self.cursor_col + 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
825
src/ui.rs
Normal file
825
src/ui.rs
Normal file
@@ -0,0 +1,825 @@
|
|||||||
|
use crate::buffer::Buffer;
|
||||||
|
use crate::editor::Editor;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FileTreeItem {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub is_dir: bool,
|
||||||
|
pub depth: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UI {
|
||||||
|
pub command_buffer: String,
|
||||||
|
pub goto_buffer: String,
|
||||||
|
pub command_history: Vec<String>,
|
||||||
|
pub command_history_index: usize,
|
||||||
|
pub command_palette_items: Vec<String>,
|
||||||
|
pub command_palette_selected: usize,
|
||||||
|
pub sidebar_width: u16,
|
||||||
|
pub file_items: Vec<(PathBuf, bool)>,
|
||||||
|
pub sidebar_selected: usize,
|
||||||
|
pub expanded_folders: HashSet<PathBuf>,
|
||||||
|
pub file_tree: Vec<FileTreeItem>,
|
||||||
|
pub can_go_up: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UI {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
UI {
|
||||||
|
command_buffer: String::new(),
|
||||||
|
goto_buffer: String::new(),
|
||||||
|
command_history: Vec::new(),
|
||||||
|
command_history_index: 0,
|
||||||
|
command_palette_items: Vec::new(),
|
||||||
|
command_palette_selected: 0,
|
||||||
|
sidebar_width: 25,
|
||||||
|
file_items: Vec::new(),
|
||||||
|
sidebar_selected: 0,
|
||||||
|
expanded_folders: HashSet::new(),
|
||||||
|
file_tree: Vec::new(),
|
||||||
|
can_go_up: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_files(&mut self) {
|
||||||
|
self.file_items.clear();
|
||||||
|
self.file_tree.clear();
|
||||||
|
|
||||||
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
|
|
||||||
|
// Fixed: properly check if we can go up
|
||||||
|
self.can_go_up = cwd.parent().is_some();
|
||||||
|
|
||||||
|
if let Ok(entries) = fs::read_dir(&cwd) {
|
||||||
|
let mut dirs: Vec<_> = entries.filter_map(|e| e.ok()).collect();
|
||||||
|
dirs.sort_by_key(|e| e.file_name());
|
||||||
|
|
||||||
|
for entry in dirs {
|
||||||
|
let path = entry.path();
|
||||||
|
if entry.file_name().to_string_lossy().starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.file_items.push((path.clone(), path.is_dir()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.build_file_tree();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_file_tree(&mut self) {
|
||||||
|
self.file_tree.clear();
|
||||||
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
|
|
||||||
|
// Add ".." entry if we can go up
|
||||||
|
if self.can_go_up {
|
||||||
|
if let Some(parent) = cwd.parent() {
|
||||||
|
self.file_tree.push(FileTreeItem {
|
||||||
|
path: parent.to_path_buf(),
|
||||||
|
is_dir: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.add_directory_contents(&cwd, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_directory_contents(&mut self, dir_path: &PathBuf, depth: usize) {
|
||||||
|
if let Ok(entries) = fs::read_dir(dir_path) {
|
||||||
|
let mut entries_vec: Vec<_> = entries.filter_map(|e| e.ok()).collect();
|
||||||
|
entries_vec.sort_by_key(|e| e.file_name());
|
||||||
|
|
||||||
|
for entry in entries_vec {
|
||||||
|
let path = entry.path();
|
||||||
|
if entry.file_name().to_string_lossy().starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_dir = path.is_dir();
|
||||||
|
self.file_tree.push(FileTreeItem {
|
||||||
|
path: path.clone(),
|
||||||
|
is_dir,
|
||||||
|
depth: depth + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_dir && self.expanded_folders.contains(&path) {
|
||||||
|
self.add_directory_contents(&path, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_folder_expand(&mut self, index: usize) -> bool {
|
||||||
|
if index >= self.file_tree.len() || index == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = &self.file_tree[index];
|
||||||
|
if !item.is_dir {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = item.path.clone();
|
||||||
|
if self.expanded_folders.contains(&path) {
|
||||||
|
self.expanded_folders.remove(&path);
|
||||||
|
self.build_file_tree();
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
self.expanded_folders.insert(path.clone());
|
||||||
|
self.build_file_tree();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sidebar_down(&mut self) {
|
||||||
|
if !self.file_tree.is_empty() {
|
||||||
|
self.sidebar_selected = (self.sidebar_selected + 1).min(self.file_tree.len() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sidebar_up(&mut self) {
|
||||||
|
self.sidebar_selected = self.sidebar_selected.saturating_sub(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_selected_file(&self) -> Option<&FileTreeItem> {
|
||||||
|
self.file_tree.get(self.sidebar_selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw(&mut self, f: &mut Frame, editor: &Editor, state: &AppState) {
|
||||||
|
self.refresh_files();
|
||||||
|
|
||||||
|
let size = f.area();
|
||||||
|
|
||||||
|
let sidebar_width = if state.sidebar_visible && state.sidebar_focus {
|
||||||
|
let max_name_len = self
|
||||||
|
.file_tree
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
item.path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().len())
|
||||||
|
.unwrap_or(0)
|
||||||
|
})
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let base_width = 24;
|
||||||
|
(max_name_len + base_width).max(24) as u16
|
||||||
|
} else {
|
||||||
|
self.sidebar_width
|
||||||
|
};
|
||||||
|
|
||||||
|
let layout = if state.sidebar_visible {
|
||||||
|
Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Length(sidebar_width), Constraint::Min(1)])
|
||||||
|
.split(size)
|
||||||
|
} else {
|
||||||
|
Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Min(1)])
|
||||||
|
.split(size)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (main_area, status_area) = if state.sidebar_visible {
|
||||||
|
let main_layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Min(1), Constraint::Length(1)])
|
||||||
|
.split(layout[1]);
|
||||||
|
(main_layout[0], main_layout[1])
|
||||||
|
} else {
|
||||||
|
let main_layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Min(1), Constraint::Length(1)])
|
||||||
|
.split(layout[0]);
|
||||||
|
(main_layout[0], main_layout[1])
|
||||||
|
};
|
||||||
|
|
||||||
|
if state.sidebar_visible {
|
||||||
|
self.draw_sidebar(f, layout[0], state, state.sidebar_focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.draw_editor(f, main_area, editor, state);
|
||||||
|
self.draw_status_bar(f, status_area, state);
|
||||||
|
|
||||||
|
if state.input_mode == crate::input::InputMode::CommandPalette {
|
||||||
|
self.draw_command_palette(f, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_sidebar(&mut self, f: &mut Frame, area: Rect, state: &AppState, focused: bool) {
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::RIGHT)
|
||||||
|
.style(Style::default().bg(Color::Rgb(30, 30, 30)));
|
||||||
|
|
||||||
|
f.render_widget(block, area);
|
||||||
|
|
||||||
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
|
let cwd_display = cwd
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "/".to_string());
|
||||||
|
|
||||||
|
let focused_bg = Color::Rgb(70, 70, 70);
|
||||||
|
let unfocused_bg = Color::Rgb(40, 40, 40);
|
||||||
|
|
||||||
|
let available_width = area.width.saturating_sub(4);
|
||||||
|
let mut items: Vec<ListItem> = Vec::new();
|
||||||
|
|
||||||
|
for (i, item) in self.file_tree.iter().enumerate() {
|
||||||
|
// Check if this is the ".." entry
|
||||||
|
let is_parent = self.can_go_up && i == 0;
|
||||||
|
|
||||||
|
let name = if is_parent {
|
||||||
|
"..".to_string()
|
||||||
|
} else {
|
||||||
|
item.path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| item.path.to_string_lossy().to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if this file is the current buffer and is modified
|
||||||
|
let modified_indicator = if !is_parent && state.file_modified {
|
||||||
|
if let Some(ref current_path) = state.current_file_path {
|
||||||
|
if item.path == *current_path {
|
||||||
|
"*"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_selected = i == self.sidebar_selected;
|
||||||
|
|
||||||
|
let expand_icon = if is_parent {
|
||||||
|
" " // No expand icon for ".."
|
||||||
|
} else if item.is_dir {
|
||||||
|
if self.expanded_folders.contains(&item.path) {
|
||||||
|
"[-] "
|
||||||
|
} else {
|
||||||
|
"[+] "
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
};
|
||||||
|
|
||||||
|
let indent = if is_parent {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
&" ".repeat(item.depth)
|
||||||
|
};
|
||||||
|
|
||||||
|
let bg = if is_selected && focused {
|
||||||
|
focused_bg
|
||||||
|
} else if is_selected {
|
||||||
|
unfocused_bg
|
||||||
|
} else {
|
||||||
|
Color::Rgb(30, 30, 30)
|
||||||
|
};
|
||||||
|
|
||||||
|
let style = if is_selected && focused {
|
||||||
|
Style::default()
|
||||||
|
.bg(bg)
|
||||||
|
.fg(Color::White)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else if item.is_dir || is_parent {
|
||||||
|
Style::default().bg(bg).fg(Color::Cyan)
|
||||||
|
} else {
|
||||||
|
Style::default().bg(bg).fg(Color::White)
|
||||||
|
};
|
||||||
|
|
||||||
|
let prefix = if is_selected { "> " } else { " " };
|
||||||
|
|
||||||
|
let full_text = format!(
|
||||||
|
"{}{}{}{}{}",
|
||||||
|
prefix, indent, expand_icon, name, modified_indicator
|
||||||
|
);
|
||||||
|
let display_text = if focused || full_text.len() <= (available_width as usize) {
|
||||||
|
full_text
|
||||||
|
} else {
|
||||||
|
let prefix_len = prefix.len();
|
||||||
|
let indent_len = indent.len();
|
||||||
|
let expand_len = expand_icon.len();
|
||||||
|
let reserved_len = prefix_len + indent_len + expand_len + 4;
|
||||||
|
let name_len = (available_width as usize).saturating_sub(reserved_len);
|
||||||
|
if name_len > 0 {
|
||||||
|
format!(
|
||||||
|
"{}{}{}{}...",
|
||||||
|
prefix,
|
||||||
|
indent,
|
||||||
|
expand_icon,
|
||||||
|
&name[..name_len.min(name.len())]
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("{}{}{}...", prefix, indent, expand_icon)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
items.push(ListItem::new(Line::from(vec![Span::styled(
|
||||||
|
display_text,
|
||||||
|
style,
|
||||||
|
)])));
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.title(format!("Files - {}", cwd_display))
|
||||||
|
.borders(Borders::NONE),
|
||||||
|
)
|
||||||
|
.style(Style::default().bg(Color::Rgb(30, 30, 30)));
|
||||||
|
|
||||||
|
f.render_widget(list, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_editor(&mut self, f: &mut Frame, area: Rect, editor: &Editor, state: &AppState) {
|
||||||
|
if let Some(buffer) = editor.current_buffer() {
|
||||||
|
self.draw_buffer(f, area, buffer, editor, state);
|
||||||
|
} else {
|
||||||
|
self.draw_welcome_screen(f, area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_buffer(
|
||||||
|
&mut self,
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
buffer: &Buffer,
|
||||||
|
editor: &Editor,
|
||||||
|
state: &AppState,
|
||||||
|
) {
|
||||||
|
let content_area = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Length(6), Constraint::Min(1)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
self.draw_line_numbers(f, content_area[0], buffer, state);
|
||||||
|
self.draw_code(f, content_area[1], buffer, editor, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_line_numbers(&mut self, f: &mut Frame, area: Rect, buffer: &Buffer, state: &AppState) {
|
||||||
|
let visible_height = area.height as usize;
|
||||||
|
let start_line = state.scroll_line;
|
||||||
|
let end_line = (start_line + visible_height).min(buffer.line_count());
|
||||||
|
|
||||||
|
let line_numbers: Vec<Line> = (start_line..end_line)
|
||||||
|
.map(|line| {
|
||||||
|
let is_current_line = line == state.cursor_line;
|
||||||
|
let line_num = if state.editor_config.relative_line_numbers && !is_current_line {
|
||||||
|
(line as i32 - state.cursor_line as i32).abs() as usize
|
||||||
|
} else {
|
||||||
|
line + 1
|
||||||
|
};
|
||||||
|
|
||||||
|
let style = if is_current_line {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::White)
|
||||||
|
.bg(Color::Rgb(60, 60, 60))
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Rgb(120, 120, 120))
|
||||||
|
};
|
||||||
|
|
||||||
|
Line::from(vec![Span::styled(format!("{:>5} ", line_num), style)])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(line_numbers)
|
||||||
|
.block(Block::default().borders(Borders::NONE))
|
||||||
|
.style(Style::default().bg(Color::Rgb(30, 30, 30)));
|
||||||
|
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_code(
|
||||||
|
&mut self,
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
buffer: &Buffer,
|
||||||
|
_editor: &Editor,
|
||||||
|
state: &AppState,
|
||||||
|
) {
|
||||||
|
let visible_height = area.height as usize;
|
||||||
|
let start_line = state.scroll_line;
|
||||||
|
let end_line = (start_line + visible_height).min(buffer.line_count());
|
||||||
|
let start_col = state.scroll_col;
|
||||||
|
let matching_bracket = buffer.find_matching_bracket();
|
||||||
|
|
||||||
|
let indent_guide_color = Color::Rgb(50, 50, 60);
|
||||||
|
let tab_width = 4;
|
||||||
|
let indent_guide_width = 2;
|
||||||
|
|
||||||
|
let content_area = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Length(indent_guide_width), Constraint::Min(1)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let indent_area = content_area[0];
|
||||||
|
let code_area = content_area[1];
|
||||||
|
|
||||||
|
let guide_lines: Vec<Line> = (start_line..end_line)
|
||||||
|
.map(|line| {
|
||||||
|
let line_content = buffer.get_line(line).unwrap_or_default();
|
||||||
|
let chars: Vec<char> = line_content.chars().collect();
|
||||||
|
|
||||||
|
let indent_level =
|
||||||
|
chars
|
||||||
|
.iter()
|
||||||
|
.take_while(|c| **c == ' ' || **c == '\t')
|
||||||
|
.fold(
|
||||||
|
0,
|
||||||
|
|acc, c| {
|
||||||
|
if *c == '\t' {
|
||||||
|
acc + tab_width
|
||||||
|
} else {
|
||||||
|
acc + 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
/ tab_width;
|
||||||
|
|
||||||
|
let is_current_line = line == state.cursor_line;
|
||||||
|
let bg = if is_current_line {
|
||||||
|
Color::Rgb(60, 60, 60)
|
||||||
|
} else {
|
||||||
|
Color::Rgb(30, 30, 30)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut guides = String::new();
|
||||||
|
for _ in 0..indent_level.min(8) {
|
||||||
|
guides.push('│');
|
||||||
|
}
|
||||||
|
|
||||||
|
let style = Style::default().fg(indent_guide_color).bg(bg);
|
||||||
|
|
||||||
|
Line::from(vec![Span::styled(guides, style)])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let guide_paragraph = Paragraph::new(guide_lines)
|
||||||
|
.block(Block::default().borders(Borders::NONE))
|
||||||
|
.style(Style::default().bg(Color::Rgb(30, 30, 30)));
|
||||||
|
|
||||||
|
f.render_widget(guide_paragraph, indent_area);
|
||||||
|
|
||||||
|
let code_lines: Vec<Line> = (start_line..end_line)
|
||||||
|
.map(|line| {
|
||||||
|
let line_content = buffer.get_line(line).unwrap_or_default();
|
||||||
|
let chars: Vec<char> = line_content.chars().collect();
|
||||||
|
|
||||||
|
let is_current_line = line == state.cursor_line;
|
||||||
|
let cursor_col_in_line = if is_current_line {
|
||||||
|
Some(state.cursor_col)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let match_pos = matching_bracket.map(|(l, c)| (l, c));
|
||||||
|
let match_col_in_line = if match_pos.map(|(l, _)| l == line).unwrap_or(false) {
|
||||||
|
Some(match_pos.unwrap().1)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
let mut current_col = 0;
|
||||||
|
|
||||||
|
for (col, ch) in chars.iter().enumerate() {
|
||||||
|
if col < start_col {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if current_col >= code_area.width as usize {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_cursor = cursor_col_in_line == Some(col);
|
||||||
|
let is_match = match_col_in_line == Some(col);
|
||||||
|
let is_bracket = matches!(
|
||||||
|
ch,
|
||||||
|
'(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' | '"' | '\''
|
||||||
|
);
|
||||||
|
|
||||||
|
let style = if is_cursor && is_bracket && matching_bracket.is_some() {
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Rgb(100, 100, 50))
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else if is_match {
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Rgb(50, 100, 50))
|
||||||
|
.fg(Color::Green)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else if is_bracket {
|
||||||
|
Style::default().fg(Color::Magenta)
|
||||||
|
} else if *ch == '"' || *ch == '\'' {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
} else if is_current_line {
|
||||||
|
Style::default().bg(Color::Rgb(60, 60, 60)).fg(Color::White)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
|
||||||
|
spans.push(Span::styled(ch.to_string(), style));
|
||||||
|
current_col += unicode_width::UnicodeWidthStr::width(ch.to_string().as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
let line_style = if is_current_line {
|
||||||
|
Style::default().bg(Color::Rgb(60, 60, 60))
|
||||||
|
} else {
|
||||||
|
Style::default().bg(Color::Rgb(30, 30, 30))
|
||||||
|
};
|
||||||
|
|
||||||
|
Line::from(spans).style(line_style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let code_paragraph = Paragraph::new(code_lines)
|
||||||
|
.block(Block::default().borders(Borders::NONE))
|
||||||
|
.style(Style::default().bg(Color::Rgb(30, 30, 30)));
|
||||||
|
|
||||||
|
f.render_widget(code_paragraph, code_area);
|
||||||
|
|
||||||
|
if state.cursor_line >= start_line && state.cursor_line < end_line {
|
||||||
|
let cursor_x = code_area.x + (state.cursor_col.saturating_sub(start_col)) as u16;
|
||||||
|
let cursor_y = code_area.y + (state.cursor_line - start_line) as u16;
|
||||||
|
if cursor_x < code_area.x + code_area.width {
|
||||||
|
use ratatui::layout::Position;
|
||||||
|
f.set_cursor_position(Position::new(cursor_x, cursor_y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_welcome_screen(&mut self, f: &mut Frame, area: Rect) {
|
||||||
|
let welcome_text = vec![
|
||||||
|
Line::from(vec![Span::styled(
|
||||||
|
"TermCode",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
.add_modifier(Modifier::ITALIC),
|
||||||
|
)]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![Span::styled(
|
||||||
|
"Version ",
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
)]),
|
||||||
|
Line::from(vec![Span::styled(
|
||||||
|
"0.1.0",
|
||||||
|
Style::default().fg(Color::Green),
|
||||||
|
)]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from("File Explorer:"),
|
||||||
|
Line::from(" Tab - Switch between sidebar/editor"),
|
||||||
|
Line::from(" Arrow keys - Navigate files"),
|
||||||
|
Line::from(" Enter - Toggle folder / Open file"),
|
||||||
|
Line::from(" Enter on .. - Go up one directory"),
|
||||||
|
Line::from(" Ctrl+b - Toggle sidebar"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from("Commands:"),
|
||||||
|
Line::from(" Ctrl+p - Command palette"),
|
||||||
|
Line::from(" Ctrl+s - Save file"),
|
||||||
|
Line::from(" Ctrl+w - Close file"),
|
||||||
|
Line::from(" Ctrl+g - Go to line"),
|
||||||
|
Line::from(" Ctrl+f - Find in file"),
|
||||||
|
Line::from(" Alt+,/. - Previous/Next buffer"),
|
||||||
|
Line::from(" Esc - Cancel / Normal mode"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(welcome_text)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::NONE)
|
||||||
|
.style(Style::default().bg(Color::Rgb(30, 30, 30))),
|
||||||
|
)
|
||||||
|
.wrap(ratatui::widgets::Wrap { trim: true });
|
||||||
|
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_status_bar(&mut self, f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
let status_text = state.status_string();
|
||||||
|
|
||||||
|
let left_content = Line::from(vec![Span::styled(
|
||||||
|
format!(" {} ", status_text),
|
||||||
|
Style::default().bg(Color::Rgb(50, 50, 50)).fg(Color::White),
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let right_content = Line::from(vec![Span::styled(
|
||||||
|
format!(" {} ", chrono::Local::now().format("%H:%M")),
|
||||||
|
Style::default().bg(Color::Rgb(50, 50, 50)).fg(Color::Gray),
|
||||||
|
)]);
|
||||||
|
|
||||||
|
// Add message if present
|
||||||
|
let mut lines = vec![left_content, right_content];
|
||||||
|
|
||||||
|
if let Some(ref message) = state.message {
|
||||||
|
let message_line = Line::from(vec![Span::styled(
|
||||||
|
format!(" {} ", message),
|
||||||
|
Style::default()
|
||||||
|
.bg(Color::Rgb(60, 60, 60))
|
||||||
|
.fg(Color::Yellow),
|
||||||
|
)]);
|
||||||
|
lines.insert(0, message_line);
|
||||||
|
}
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(lines)
|
||||||
|
.block(Block::default().borders(Borders::NONE))
|
||||||
|
.style(Style::default().bg(Color::Rgb(50, 50, 50)));
|
||||||
|
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_command_palette(&mut self, f: &mut Frame, area: Rect) {
|
||||||
|
self.command_palette_items = vec![
|
||||||
|
"Save File (Ctrl+s)".to_string(),
|
||||||
|
"Close File (Ctrl+w)".to_string(),
|
||||||
|
"Toggle Sidebar (Ctrl+b)".to_string(),
|
||||||
|
"Go to Line (Ctrl+g)".to_string(),
|
||||||
|
"Next Buffer (Alt+.)".to_string(),
|
||||||
|
"Previous Buffer (Alt+,)".to_string(),
|
||||||
|
"Quit (Ctrl+q)".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let palette_height = std::cmp::min(15, area.height as usize - 4);
|
||||||
|
let palette_area = Rect::new(
|
||||||
|
area.x + 2,
|
||||||
|
area.y + 2,
|
||||||
|
area.width - 4,
|
||||||
|
palette_height as u16,
|
||||||
|
);
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title("Command Palette")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().bg(Color::Rgb(40, 40, 40)));
|
||||||
|
|
||||||
|
f.render_widget(Clear, palette_area);
|
||||||
|
f.render_widget(block, palette_area);
|
||||||
|
|
||||||
|
let list_items: Vec<ListItem> = self
|
||||||
|
.command_palette_items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, item)| {
|
||||||
|
let style = if i == self.command_palette_selected {
|
||||||
|
Style::default().bg(Color::Blue).fg(Color::White)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
let content = Line::from(vec![Span::styled(format!(" {}", item), style)]);
|
||||||
|
ListItem::new(content)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let list = List::new(list_items)
|
||||||
|
.block(Block::default().borders(Borders::NONE))
|
||||||
|
.style(Style::default().bg(Color::Rgb(40, 40, 40)));
|
||||||
|
|
||||||
|
let inner_area = Rect::new(
|
||||||
|
palette_area.x + 1,
|
||||||
|
palette_area.y + 2,
|
||||||
|
palette_area.width - 2,
|
||||||
|
palette_area.height - 3,
|
||||||
|
);
|
||||||
|
|
||||||
|
f.render_widget(list, inner_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_key(&mut self, key: KeyEvent, state: &mut AppState) -> bool {
|
||||||
|
match state.input_mode {
|
||||||
|
crate::input::InputMode::Normal => self.handle_normal_mode(key, state),
|
||||||
|
crate::input::InputMode::Command => self.handle_command_mode(key, state),
|
||||||
|
crate::input::InputMode::GotoLine => self.handle_goto_mode(key, state),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_normal_mode(&mut self, key: KeyEvent, state: &mut AppState) -> bool {
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
|
||||||
|
state.set_input_mode(crate::input::InputMode::Command);
|
||||||
|
self.command_buffer.clear();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
(KeyCode::Esc, _) => {
|
||||||
|
state.set_input_mode(crate::input::InputMode::Normal);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_command_mode(&mut self, key: KeyEvent, state: &mut AppState) -> bool {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
state.set_input_mode(crate::input::InputMode::Normal);
|
||||||
|
self.command_buffer.clear();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if !self.command_buffer.is_empty() {
|
||||||
|
state.add_command_to_history(self.command_buffer.clone());
|
||||||
|
self.command_history.push(self.command_buffer.clone());
|
||||||
|
state.set_message(format!("Command: {}", self.command_buffer));
|
||||||
|
}
|
||||||
|
self.command_buffer.clear();
|
||||||
|
state.set_input_mode(crate::input::InputMode::Normal);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
self.command_buffer.pop();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
if let Some(cmd) = self.previous_command() {
|
||||||
|
self.command_buffer = cmd.clone();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if let Some(cmd) = self.next_command() {
|
||||||
|
self.command_buffer = cmd.clone();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
KeyCode::Char(ch) => {
|
||||||
|
self.command_buffer.push(ch);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_goto_mode(&mut self, key: KeyEvent, state: &mut AppState) -> bool {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
state.set_input_mode(crate::input::InputMode::Normal);
|
||||||
|
self.goto_buffer.clear();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if let Ok(line) = self.goto_buffer.parse::<usize>() {
|
||||||
|
let line_num = line.saturating_sub(1);
|
||||||
|
state.set_cursor_position(line_num, 0);
|
||||||
|
state.set_message(format!("Going to line {}", line));
|
||||||
|
}
|
||||||
|
self.goto_buffer.clear();
|
||||||
|
state.set_input_mode(crate::input::InputMode::Normal);
|
||||||
|
true // Return true to signal that cursor position changed
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
self.goto_buffer.pop();
|
||||||
|
false
|
||||||
|
}
|
||||||
|
KeyCode::Char(ch) if ch.is_ascii_digit() => {
|
||||||
|
self.goto_buffer.push(ch);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn previous_command(&mut self) -> Option<&String> {
|
||||||
|
if !self.command_history.is_empty() && self.command_history_index > 0 {
|
||||||
|
self.command_history_index -= 1;
|
||||||
|
return self.command_history.get(self.command_history_index);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_command(&mut self) -> Option<&String> {
|
||||||
|
if self.command_history_index < self.command_history.len() {
|
||||||
|
self.command_history_index += 1;
|
||||||
|
return self
|
||||||
|
.command_history
|
||||||
|
.get(self.command_history_index.saturating_sub(1));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_selected_go_up(&self) -> bool {
|
||||||
|
self.can_go_up && self.sidebar_selected == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_selected_command(&self) -> Option<&String> {
|
||||||
|
self.command_palette_items
|
||||||
|
.get(self.command_palette_selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
termcode.svg
Normal file
19
termcode.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||||
|
<rect width="128" height="128" rx="16" fill="#1e1e1e"/>
|
||||||
|
<rect x="16" y="16" width="96" height="20" rx="4" fill="#3d3d3d"/>
|
||||||
|
<circle cx="28" cy="26" r="4" fill="#ff5f56"/>
|
||||||
|
<circle cx="44" cy="26" r="4" fill="#ffbd2e"/>
|
||||||
|
<circle cx="60" cy="26" r="4" fill="#27c93f"/>
|
||||||
|
<rect x="16" y="44" width="60" height="4" rx="2" fill="#4a4a4a"/>
|
||||||
|
<rect x="16" y="56" width="80" height="4" rx="2" fill="#4a4a4a"/>
|
||||||
|
<rect x="16" y="68" width="72" height="4" rx="2" fill="#4a4a4a"/>
|
||||||
|
<rect x="16" y="80" width="84" height="4" rx="2" fill="#4a4a4a"/>
|
||||||
|
<rect x="16" y="92" width="48" height="4" rx="2" fill="#4a4a4a"/>
|
||||||
|
<rect x="16" y="104" width="76" height="4" rx="2" fill="#4a4a4a"/>
|
||||||
|
<rect x="88" y="44" width="24" height="60" rx="4" fill="#2d2d2d"/>
|
||||||
|
<rect x="92" y="52" width="16" height="4" rx="2" fill="#3d3d3d"/>
|
||||||
|
<rect x="92" y="64" width="12" height="4" rx="2" fill="#3d3d3d"/>
|
||||||
|
<rect x="92" y="76" width="16" height="4" rx="2" fill="#3d3d3d"/>
|
||||||
|
<rect x="92" y="88" width="8" height="4" rx="2" fill="#3d3d3d"/>
|
||||||
|
<rect x="92" y="100" width="14" height="4" rx="2" fill="#3d3d3d"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
Reference in New Issue
Block a user