Initial Commit

This commit is contained in:
2026-01-21 17:37:37 +01:00
commit 58f5d4505d
12 changed files with 4374 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

1761
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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