commit 17ae495843fcb799eae28dc7ba39fab1bd7ecff1
parent 8e197646ed86e20c8cda95a7e057b84830e8fdae
Author: minerva-jupiter <ryouturn@gmail.com>
Date: Sun, 7 Jun 2026 11:42:40 +0900
feat: implement core editor functionality with swap and recovery support
- Add `Buffer` for on-demand file I/O, delta management, and swap file persistence.
- Implement `Editor` state machine for navigation and mode switching.
- Add swap file detection and recovery prompts to `main.rs`.
- Implement basic text editing (Insert mode) and atomic saving.
- Update `README.md` with new features and roadmap progress.
Diffstat:
| M | README.md | | | 154 | +++++++++++++++++++++++++++++++++++++------------------------------------------ |
| A | src/buffer.rs | | | 198 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/editor.rs | | | 59 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/main.rs | | | 343 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
4 files changed, 670 insertions(+), 84 deletions(-)
diff --git a/README.md b/README.md
@@ -1,82 +1,72 @@
-# pim - A Lightweight Minimalist Text Editor written in Rust
-
-`pim` は、Rustで開発されるテキストエディタである。初期実装として `nano` のようなミニマリズムと省メモリ設計(オンデマンド読み込み)を採用しつつ、内部構造は `vim` のようなモード管理(ステートマシン)が可能なアーキテクチャを採用する。
-
-## 1. 動作環境・技術スタック
-
-* **Language**: Rust
-* **TUI Framework**: `ratatui` (v0.26以降想定)
-* **Terminal Control (Backend)**: `crossterm`
-* **Target OS**: Linux, macOS, Windows
-
----
-
-## 2. コア仕様 (Specifications)
-
-### 2.1. モード管理 (State Machine)
-起動時は常に `Normal` モードから開始する。入力されたキーイベントは直接テキストバッファに渡されず、現在のモードに応じて `Action` に変換されてから処理される。
-
-* **Normal モード**
- * `i`: `Insert` モードへ移行
- * `j`: カーソルを下へ移動
- * `k`: カーソルを上へ移動
- * `:q`: エディタの終了 (コマンドラインモード実装までの暫定)
-* **Insert モード**
- * `Esc`: `Normal` モードへ移行
- * 一般文字キー: テキストバッファへの文字挿入
- * `Backspace`: 文字の削除
-
-### 2.2. 省メモリ設計 (On-demand File I/O)
-数GB規模の巨大ファイルを開いた際、メモリのパンクを防ぐために以下の仕様を満たす。
-1. **行インデックス化**: ファイルオープン時、ファイル全体はメモリに読み込まず、各行の開始バイト位置(オフセット)のみを `Vec<usize>` に記録する。
-2. **オンデマンド描画**: 画面のスクロール位置に対応する行のバイト範囲のみをファイルから `seek` して読み込み、`ratatui` の描画バッファへ渡す。
-3. **差分バッファ管理**: 編集内容(挿入・削除)は元のファイルバッファを書き換えず、どの行のどの位置に変更があったかを別構造(Delta Queue)で管理する。画面描画時は、ファイルから読み込んだデータにこの差分を動的にパッチ当てして表示する。
-
----
-
-## 3. アーキテクチャとデータ構造
-
-### 3.1. モードおよびアクションの定義
-```rust
-pub enum Mode {
- Normal,
- Insert,
-}
-```
-
-### 3.2. テキストインデックスと差分管理
-
----
-
-## 4. 実装ステップ (Implementation Roadmap)
-
-### フェーズ 1: ターミナル制御とモード遷移の確立 (土台)
-
-`crossterm` と `ratatui` を統合し、イベントループと画面の基本レイアウトを構築する。
-
-* [ ] Rawモードの有効化とオルタネイトスクリーンの展開
-* [ ] 画面最下部へのステータスバー(現在の `Mode` 表示)の配置
-* [ ] キー入力イベントから `Action` への変換およびモード遷移のテスト
-
-### フェーズ 2: 巨大ファイルのインデックス化と部分読み込み (閲覧)
-
-テキストの編集を行わず、任意のサイズのファイルを最小のメモリで表示・スクロールできる状態を実装する。
-
-* [ ] ファイルをスキャンし、改行コードを元に `LineIndex` を生成するロジックの実装
-* [ ] ターミナルの行数分だけファイルを `seek` して読み込む描画ロジックの実装
-* [ ] 矢印キー(または `j`/`k`)によるスクロール移動の実装
-
-### フェーズ 3: 差分バッファの動的適用 (編集)
-
-メモリ上のファイルデータに対して変更履歴をオーバーレイさせ、画面に反映する。
-
-* [ ] `DeltaBuffer` のデータ構造の定義
-* [ ] ファイルから読み込んだ生の行データに対し、`DeltaBuffer` 内の `EditOp` を適用して文字列を合成する関数の実装
-* [ ] Insertモード時における文字入力の `DeltaBuffer` への記録
-
-### フェーズ 4: アトミックな書き込み (保存)
-
-編集されたデータを元のファイル、あるいは新しいファイルへ安全に出力する。
-
-* [ ] `LineIndex` と `DeltaBuffer` を走査し、1行ずつストリームとしてディスクへ書き出すロジックの実装
-* [ ] 書き込み完了後の `LineIndex` の再構築と `DeltaBuffer` のクリア
+# pim - A Lightweight Minimalist Text Editor written in Rust
+
+`pim` は、Rustで開発されるテキストエディタである。初期実装として `nano` のようなミニマリズムと省メモリ設計(オンデマンド読み込み)を採用しつつ、内部構造は `vim` のようなモード管理(ステートマシン)が可能なアーキテクチャを採用する。
+
+## 1. 動作環境・技術スタック
+
+* **Language**: Rust (Edition 2024)
+* **TUI Framework**: `ratatui`
+* **Terminal Control (Backend)**: `crossterm`
+* **Target OS**: Linux, macOS, Windows
+
+---
+
+## 2. コア仕様 (Specifications)
+
+### 2.1. モード管理 (State Machine)
+起動時は常に `Normal` モードから開始する。入力されたキーイベントは直接テキストバッファに渡されず、現在のモードに応じて `Action` に変換されてから処理される。
+
+* **Normal モード**
+ * `i`: `Insert` モードへ移行
+ * `h`, `j`, `k`, `l`: カーソル移動
+ * `w`: ファイルの保存
+ * `u`: Undo (前回の保存時点までロールバック)
+ * `q`: エディタの終了
+* **Insert モード**
+ * `Esc`: `Normal` モードへ移行
+ * 一般文字キー: カーソル位置への文字挿入
+ * `Backspace`: 文字の削除
+
+### 2.2. 省メモリ設計 (On-demand File I/O)
+1. **行インデックス化**: ファイルオープン時、各行の開始バイト位置のみを記録し、メモリ消費を抑える。
+2. **オンデマンド描画**: 画面の表示範囲のみを `seek` して読み込む。
+3. **差分バッファ管理**: 編集内容は `HashMap` で管理し、元の巨大ファイルを読み書きする負荷を最小化する。
+
+### 2.3. データ保護 (Swap File & Safety)
+1. **スワップファイル**: 編集内容は自動的に `.swp` ファイルへ同期(Insertモード終了時にバッチ書き込み)。
+2. **リカバリ機能**: クラッシュ後の再起動時に未保存の変更を復元可能。
+3. **アトミック保存**: 保存時は一時ファイルを作成してから置換し、改行コードは常に `LF` に統一される。
+
+---
+
+## 3. 実装ステップ (Implementation Roadmap)
+
+### フェーズ 1: ターミナル制御とモード遷移の確立 (土台)
+* [x] Rawモードの有効化とオルタネイトスクリーンの展開
+* [x] 画面最下部へのステータスバー(現在の `Mode` 表示)の配置
+* [x] キー入力イベントから `Action` への変換およびモード遷移のテスト
+
+### フェーズ 2: 巨大ファイルのインデックス化と部分読み込み (閲覧)
+* [x] ファイルをスキャンし、改行コードを元に `LineIndex` を生成するロジックの実装
+* [x] ターミナルの行数分だけファイルを `seek` して読み込む描画ロジックの実装
+* [x] 矢印キー(または `j`/`k`)によるスクロール移動の実装
+* [x] 行番号の表示実装
+
+### フェーズ 3: 差分バッファの動的適用 (編集)
+* [x] `DeltaBuffer` のデータ構造の定義
+* [x] スワップファイル(`.swp`)による自動保存とリカバリプロンプトの実装
+* [x] ビジュアルカーソルの実装と行内移動(`h`/`l`)の対応
+* [x] Insertモード終了時の一括スワップ同期(パフォーマンス最適化)
+
+### フェーズ 4: アトミックな書き込み (保存)
+* [x] 常に `LF` 形式での保存ロジックの実装
+* [x] `u` キーによる保存単位のロールバック(ハイブリッド・スワップ管理)
+* [x] 終了時の未保存変更チェックとクリーンアップ
+
+---
+
+## 4. 使い方
+
+```bash
+cargo run -- <filename>
+```
diff --git a/src/buffer.rs b/src/buffer.rs
@@ -0,0 +1,198 @@
+use std::collections::HashMap;
+use std::fs::{self, File, OpenOptions};
+use std::io::{self, BufRead, BufReader, Seek, SeekFrom, Write};
+use std::path::PathBuf;
+
+pub struct Buffer {
+ file_path: PathBuf,
+ swap_path: PathBuf,
+ line_offsets: Vec<u64>,
+ deltas: HashMap<usize, String>,
+ modified: bool,
+}
+
+impl Buffer {
+ pub fn new_empty(path: PathBuf) -> Self {
+ let swap_path = path.with_extension(format!(
+ "{}.swp",
+ path.extension().and_then(|e| e.to_str()).unwrap_or("txt")
+ ));
+ Self {
+ file_path: path,
+ swap_path,
+ line_offsets: vec![0],
+ deltas: HashMap::new(),
+ modified: false,
+ }
+ }
+
+ pub fn from_path(path: PathBuf, should_recover: bool) -> io::Result<Self> {
+ let file = File::open(&path)?;
+ let mut reader = BufReader::new(file);
+ let mut line_offsets = vec![0];
+ let mut offset = 0;
+
+ let mut buf = Vec::new();
+ while reader.read_until(b'\n', &mut buf)? > 0 {
+ offset += buf.len() as u64;
+ line_offsets.push(offset);
+ buf.clear();
+ }
+
+ let swap_path = path.with_extension(format!(
+ "{}.swp",
+ path.extension().and_then(|e| e.to_str()).unwrap_or("txt")
+ ));
+
+ let mut deltas = HashMap::new();
+ let mut modified = false;
+ if swap_path.exists() && should_recover {
+ let swap_file = File::open(&swap_path)?;
+ let swap_reader = BufReader::new(swap_file);
+ for line in swap_reader.lines() {
+ let line = line?;
+ if line == "SAVE" {
+ modified = false;
+ continue;
+ }
+ if let Some((idx_str, content)) = line.split_once(':')
+ && let Ok(idx) = idx_str.parse::<usize>()
+ {
+ deltas.insert(idx, content.to_string());
+ modified = true;
+ }
+ }
+ } else if swap_path.exists() {
+ fs::remove_file(&swap_path)?;
+ }
+
+ Ok(Self {
+ file_path: path,
+ swap_path,
+ line_offsets,
+ deltas,
+ modified,
+ })
+ }
+
+ pub fn read_line(&self, index: usize) -> io::Result<String> {
+ if let Some(delta) = self.deltas.get(&index) {
+ return Ok(delta.clone());
+ }
+
+ if index >= self.line_offsets.len().saturating_sub(1) {
+ return Ok(String::new());
+ }
+
+ let mut file = File::open(&self.file_path)?;
+ file.seek(SeekFrom::Start(self.line_offsets[index]))?;
+
+ let length = self.line_offsets[index + 1] - self.line_offsets[index];
+ let mut buf = vec![0; length as usize];
+ io::Read::read_exact(&mut file, &mut buf)?;
+
+ Ok(String::from_utf8_lossy(&buf).to_string())
+ }
+
+ pub fn update_line(&mut self, index: usize, content: String) {
+ self.deltas.insert(index, content);
+ self.modified = true;
+ }
+
+ pub fn sync_to_swap(&self) -> io::Result<()> {
+ let mut swap_file = OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&self.swap_path)?;
+
+ // 現在の全ての差分をスワップファイルに書き出す
+ // 本来は「今回の編集セッションの変更点」だけに絞るのが効率的だが、
+ // 現状のログ形式を維持するため、全ての deltas を再出力する
+ for (idx, content) in &self.deltas {
+ writeln!(swap_file, "{}:{}", idx, content.replace(['\n', '\r'], ""))?;
+ }
+ Ok(())
+ }
+
+ pub fn save(&mut self) -> io::Result<()> {
+ let temp_path = self.file_path.with_extension("tmp");
+ let mut temp_file = File::create(&temp_path)?;
+
+ for i in 0..self.total_lines().max(1) {
+ let line = self.read_line(i)?;
+ let content = line.replace(['\n', '\r'], "");
+ #[allow(clippy::write_with_newline)]
+ write!(temp_file, "{}\n", content)?;
+ }
+
+ fs::rename(temp_path, &self.file_path)?;
+
+ let mut swap_file = OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&self.swap_path)?;
+ writeln!(swap_file, "SAVE")?;
+
+ self.modified = false;
+ Ok(())
+ }
+
+ pub fn rollback(&mut self) -> io::Result<()> {
+ if !self.swap_path.exists() {
+ return Ok(());
+ }
+
+ let swap_file = File::open(&self.swap_path)?;
+ let lines: Vec<String> = BufReader::new(swap_file)
+ .lines()
+ .collect::<Result<_, _>>()?;
+
+ let last_save_idx = lines.iter().rposition(|l| l == "SAVE");
+ let target_idx = if let Some(idx) = last_save_idx {
+ lines[..idx].iter().rposition(|l| l == "SAVE")
+ } else {
+ None
+ };
+
+ let mut new_deltas = HashMap::new();
+ let limit = target_idx.map(|i| i + 1).unwrap_or(0);
+
+ for line in &lines[..limit] {
+ if line == "SAVE" {
+ continue;
+ }
+ if let Some((idx_str, content)) = line.split_once(':')
+ && let Ok(idx) = idx_str.parse::<usize>()
+ {
+ new_deltas.insert(idx, content.to_string());
+ }
+ }
+
+ let mut swap_file = File::create(&self.swap_path)?;
+ for line in &lines[..limit] {
+ writeln!(swap_file, "{}", line)?;
+ }
+
+ self.deltas = new_deltas;
+ self.modified = false;
+ Ok(())
+ }
+
+ pub fn is_modified(&self) -> bool {
+ self.modified
+ }
+
+ pub fn total_lines(&self) -> usize {
+ self.line_offsets
+ .len()
+ .saturating_sub(1)
+ .max(self.deltas.keys().map(|&k| k + 1).max().unwrap_or(0))
+ }
+
+ pub fn remove_swap_file(&self) -> io::Result<()> {
+ if self.swap_path.exists() {
+ fs::remove_file(&self.swap_path)?;
+ }
+ Ok(())
+ }
+}
diff --git a/src/editor.rs b/src/editor.rs
@@ -0,0 +1,59 @@
+#[derive(PartialEq)]
+pub enum Mode {
+ Normal,
+ Insert,
+}
+
+pub struct Editor {
+ pub mode: Mode,
+ pub scroll_offset: usize,
+ pub cursor_line: usize,
+ pub cursor_column: usize,
+}
+
+impl Editor {
+ pub fn new() -> Self {
+ Self {
+ mode: Mode::Normal,
+ scroll_offset: 0,
+ cursor_line: 0,
+ cursor_column: 0,
+ }
+ }
+
+ pub fn scroll_down(&mut self, total_lines: usize, view_height: usize) {
+ if self.scroll_offset + view_height < total_lines {
+ self.scroll_offset += 1;
+ }
+ }
+
+ pub fn scroll_up(&mut self) {
+ if self.scroll_offset > 0 {
+ self.scroll_offset -= 1;
+ }
+ }
+
+ pub fn move_cursor_down(&mut self, total_lines: usize) {
+ if self.cursor_line + 1 < total_lines {
+ self.cursor_line += 1;
+ }
+ }
+
+ pub fn move_cursor_up(&mut self) {
+ if self.cursor_line > 0 {
+ self.cursor_line -= 1;
+ }
+ }
+
+ pub fn move_cursor_left(&mut self) {
+ if self.cursor_column > 0 {
+ self.cursor_column -= 1;
+ }
+ }
+
+ pub fn move_cursor_right(&mut self, line_len: usize) {
+ if self.cursor_column < line_len {
+ self.cursor_column += 1;
+ }
+ }
+}
diff --git a/src/main.rs b/src/main.rs
@@ -1,3 +1,342 @@
-fn main() {
- println!("Hello, world!");
+mod buffer;
+mod editor;
+
+use crossterm::{
+ event::{self, Event, KeyCode},
+ execute,
+ terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
+};
+use ratatui::{
+ Terminal,
+ backend::CrosstermBackend,
+ layout::{Constraint, Direction, Layout},
+ widgets::Paragraph,
+};
+use std::io::{self, Stdout, stdout};
+use std::path::PathBuf;
+
+use crate::buffer::Buffer;
+use crate::editor::{Editor, Mode};
+
+enum AppState {
+ Running,
+ SwapDetected(PathBuf),
+ NewFilePrompt(PathBuf),
+ UnsavedChangesPrompt,
+ Quitting,
+}
+
+fn main() -> io::Result<()> {
+ let mut terminal = setup_terminal()?;
+
+ let path = std::env::args().nth(1).map(PathBuf::from);
+ let mut buffer = None;
+ let mut state = AppState::Running;
+
+ if let Some(ref p) = path {
+ let swap_path = p.with_extension(format!(
+ "{}.swp",
+ p.extension().and_then(|e| e.to_str()).unwrap_or("txt")
+ ));
+ if swap_path.exists() {
+ state = AppState::SwapDetected(p.clone());
+ } else if !p.exists() {
+ state = AppState::NewFilePrompt(p.clone());
+ } else {
+ buffer = Some(Buffer::from_path(p.clone(), true)?);
+ }
+ }
+
+ let result = run(&mut terminal, buffer, state, path);
+ restore_terminal(&mut terminal)?;
+ result
+}
+
+fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
+ enable_raw_mode()?;
+ let mut stdout = stdout();
+ execute!(stdout, EnterAlternateScreen)?;
+ let backend = CrosstermBackend::new(stdout);
+ Terminal::new(backend)
+}
+
+fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
+ disable_raw_mode()?;
+ execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
+ terminal.show_cursor()
+}
+
+fn run(
+ terminal: &mut Terminal<CrosstermBackend<Stdout>>,
+ mut buffer: Option<Buffer>,
+ mut state: AppState,
+ _path: Option<PathBuf>,
+) -> io::Result<()> {
+ let mut editor = Editor::new();
+
+ loop {
+ match state {
+ AppState::Quitting => {
+ if let Some(ref b) = buffer {
+ let _ = b.remove_swap_file();
+ }
+ break;
+ }
+ AppState::SwapDetected(ref p) => {
+ terminal.draw(|f| {
+ let content = format!(
+ "Swap file detected for {:?}. \nRecover? (y: Yes, n: No/Discard)",
+ p
+ );
+ f.render_widget(Paragraph::new(content), f.area());
+ })?;
+
+ if let Ok(Event::Key(key)) = event::read()
+ && key.kind == event::KeyEventKind::Press
+ {
+ match key.code {
+ KeyCode::Char('y') => {
+ buffer = Some(Buffer::from_path(p.clone(), true)?);
+ state = AppState::Running;
+ }
+ KeyCode::Char('n') => {
+ buffer = Some(Buffer::from_path(p.clone(), false)?);
+ state = AppState::Running;
+ }
+ _ => {}
+ }
+ }
+ }
+ AppState::NewFilePrompt(ref p) => {
+ terminal.draw(|f| {
+ let content = format!(
+ "File {:?} does not exist. \nCreate new file? (y: Yes, n: No/Quit)",
+ p
+ );
+ f.render_widget(Paragraph::new(content), f.area());
+ })?;
+
+ if let Ok(Event::Key(key)) = event::read()
+ && key.kind == event::KeyEventKind::Press
+ {
+ match key.code {
+ KeyCode::Char('y') => {
+ buffer = Some(Buffer::new_empty(p.clone()));
+ state = AppState::Running;
+ }
+ KeyCode::Char('n') => {
+ state = AppState::Quitting;
+ }
+ _ => {}
+ }
+ }
+ }
+ AppState::UnsavedChangesPrompt => {
+ terminal.draw(|f| {
+ let content = "You have unsaved changes. \nSave and quit? (y: Save & Quit, n: Discard & Quit, c: Cancel)";
+ f.render_widget(Paragraph::new(content), f.area());
+ })?;
+
+ if let Ok(Event::Key(key)) = event::read()
+ && key.kind == event::KeyEventKind::Press
+ {
+ match key.code {
+ KeyCode::Char('y') => {
+ if let Some(ref mut b) = buffer {
+ b.save()?;
+ }
+ state = AppState::Quitting;
+ }
+ KeyCode::Char('n') => {
+ state = AppState::Quitting;
+ }
+ KeyCode::Char('c') => {
+ state = AppState::Running;
+ }
+ _ => {}
+ }
+ }
+ }
+ AppState::Running => {
+ let mut line_num_width = 0;
+ terminal.draw(|f| {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Min(0), Constraint::Length(1)])
+ .split(f.area());
+
+ let mut display_content = String::new();
+ if let Some(ref b) = buffer {
+ let view_height = chunks[0].height as usize;
+ let total_lines = b.total_lines();
+ line_num_width = total_lines.to_string().len().max(1);
+
+ let start = editor.scroll_offset;
+ let end = (start + view_height).min(total_lines.max(1));
+
+ for i in start..end {
+ if let Ok(line) = b.read_line(i) {
+ let line_content = line.replace(['\n', '\r'], "");
+ let line_display = format!(
+ " {:>width$} | {}\n",
+ i + 1,
+ line_content,
+ width = line_num_width
+ );
+ display_content.push_str(&line_display);
+ }
+ }
+ }
+
+ f.render_widget(Paragraph::new(display_content), chunks[0]);
+
+ let mode_str = match editor.mode {
+ Mode::Normal => "NORMAL",
+ Mode::Insert => "INSERT",
+ };
+
+ let line_count = buffer.as_ref().map(|b| b.total_lines()).unwrap_or(0);
+ let modified_flag = if buffer.as_ref().map(|b| b.is_modified()).unwrap_or(false)
+ {
+ " [modified]"
+ } else {
+ ""
+ };
+
+ let key_hint = match editor.mode {
+ Mode::Normal => "'w': Save, 'u': Undo, 'q': Quit, 'h/j/k/l': Move",
+ Mode::Insert => "'ESC': Exit Insert mode",
+ };
+
+ let status = format!(
+ "Mode: {}{} | Lines: {} | Cursor: {}:{} | {}",
+ mode_str,
+ modified_flag,
+ line_count,
+ editor.cursor_line + 1,
+ editor.cursor_column + 1,
+ key_hint
+ );
+
+ f.render_widget(Paragraph::new(status), chunks[1]);
+ })?;
+
+ // カーソル位置の調整
+ if let Some(ref b) = buffer {
+ let line = b.read_line(editor.cursor_line).unwrap_or_default();
+ let line_len = line.replace(['\n', '\r'], "").len();
+ let max_column = if editor.mode == Mode::Normal {
+ line_len.saturating_sub(1)
+ } else {
+ line_len
+ };
+ if editor.cursor_column > max_column {
+ editor.cursor_column = max_column;
+ }
+
+ let x = (2 + line_num_width + 3 + editor.cursor_column) as u16;
+ let y = (editor.cursor_line - editor.scroll_offset) as u16;
+ terminal.set_cursor_position((x, y))?;
+ }
+ terminal.show_cursor()?;
+
+ if let Event::Key(key) = event::read()? {
+ if key.kind != event::KeyEventKind::Press {
+ continue;
+ }
+
+ match editor.mode {
+ Mode::Normal => match key.code {
+ KeyCode::Char('q') => {
+ if buffer.as_ref().map(|b| b.is_modified()).unwrap_or(false) {
+ state = AppState::UnsavedChangesPrompt;
+ } else {
+ state = AppState::Quitting;
+ }
+ }
+ KeyCode::Char('w') => {
+ if let Some(ref mut b) = buffer {
+ b.save()?;
+ }
+ }
+ KeyCode::Char('u') => {
+ if let Some(ref mut b) = buffer {
+ b.rollback()?;
+ let line = b.read_line(editor.cursor_line).unwrap_or_default();
+ let line_len = line.replace(['\n', '\r'], "").len();
+ if editor.cursor_column >= line_len {
+ editor.cursor_column = line_len.saturating_sub(1);
+ }
+ }
+ }
+ KeyCode::Char('i') => editor.mode = Mode::Insert,
+ KeyCode::Char('h') => editor.move_cursor_left(),
+ KeyCode::Char('l') => {
+ if let Some(ref b) = buffer {
+ let line = b.read_line(editor.cursor_line).unwrap_or_default();
+ let line_len = line.replace(['\n', '\r'], "").len();
+ editor.move_cursor_right(line_len.saturating_sub(1));
+ }
+ }
+ KeyCode::Char('j') => {
+ if let Some(ref b) = buffer {
+ let total = b.total_lines().max(1);
+ editor.move_cursor_down(total);
+ if editor.cursor_line
+ >= editor.scroll_offset + terminal.size()?.height as usize
+ - 1
+ {
+ editor.scroll_down(total, 1);
+ }
+ }
+ }
+ KeyCode::Char('k') => {
+ if buffer.is_some() {
+ editor.move_cursor_up();
+ if editor.cursor_line < editor.scroll_offset {
+ editor.scroll_up();
+ }
+ }
+ }
+ _ => {}
+ },
+ Mode::Insert => {
+ if key.code == KeyCode::Esc {
+ editor.mode = Mode::Normal;
+ if let Some(ref mut b) = buffer {
+ // Insertモード終了時にスワップファイルに書き出す
+ b.sync_to_swap()?;
+
+ let line = b.read_line(editor.cursor_line).unwrap_or_default();
+ let line_len = line.replace(['\n', '\r'], "").len();
+ if editor.cursor_column >= line_len && line_len > 0 {
+ editor.cursor_column = line_len - 1;
+ }
+ }
+ } else if let KeyCode::Char(c) = key.code {
+ if let Some(ref mut b) = buffer {
+ let mut current_line =
+ b.read_line(editor.cursor_line).unwrap_or_default();
+ current_line = current_line.replace(['\n', '\r'], "");
+ current_line.insert(editor.cursor_column, c);
+ b.update_line(editor.cursor_line, current_line);
+ editor.cursor_column += 1;
+ }
+ } else if let (KeyCode::Backspace, Some(b)) = (key.code, &mut buffer)
+ && editor.cursor_column > 0
+ {
+ let mut current_line =
+ b.read_line(editor.cursor_line).unwrap_or_default();
+ current_line = current_line.replace(['\n', '\r'], "");
+ current_line.remove(editor.cursor_column - 1);
+ b.update_line(editor.cursor_line, current_line);
+ editor.cursor_column -= 1;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ Ok(())
}