From d6aaf4e8af7480ba4578d6e6353d2dee454bcd5a Mon Sep 17 00:00:00 2001 From: vasilito Date: Sat, 20 Jun 2026 11:18:28 +0300 Subject: [PATCH] tlc: cursor position save/restore (filepos) Adds per-file cursor position persistence (MC ~/.mc/filepos parity). Storage: ~/.config/tlc/filepos as a tab-separated canonical-path database. Wired into editor/viewer open+close in both standalone binaries and the in-TLC file manager. CursorPos struct, save/load functions, restore_cursor_position() and save_cursor_position() methods on Editor and Viewer. buttonbar.rs: add module-level doc to satisfy missing_docs lint. --- .../tui/tlc/source/src/editor/filepos.rs | 154 ++++++++++++++++++ .../tlc/source/src/filemanager/dialog_ops.rs | 11 +- .../tui/tlc/source/src/filemanager/mod.rs | 12 +- .../recipes/tui/tlc/source/src/viewer/mod.rs | 24 +++ .../tui/tlc/source/src/widget/buttonbar.rs | 6 + 5 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 local/recipes/tui/tlc/source/src/editor/filepos.rs diff --git a/local/recipes/tui/tlc/source/src/editor/filepos.rs b/local/recipes/tui/tlc/source/src/editor/filepos.rs new file mode 100644 index 0000000000..d245f591d5 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/editor/filepos.rs @@ -0,0 +1,154 @@ +use std::env; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// Saved cursor line/column for a file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CursorPos { + /// 1-based line number. + pub line: u32, + /// 0-based column on that line. + pub column: u32, +} + +fn config_dir() -> Option { + let base = env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?; + let tlc_dir = base.join("tlc"); + if !tlc_dir.exists() { + let _ = fs::create_dir_all(&tlc_dir); + } + Some(tlc_dir) +} + +fn file_path() -> Option { + config_dir().map(|d| d.join("filepos")) +} + +/// Load the saved cursor position for `path`, if any. +pub fn load(path: &Path) -> Option { + let fp = file_path()?; + let text = fs::read_to_string(&fp).ok()?; + let key = canonical_key(path); + for line in text.lines() { + let mut parts = line.splitn(3, '\t'); + let k = parts.next()?; + let ln = parts.next()?.parse::().ok()?; + let col = parts.next()?.parse::().ok()?; + if k == key { + return Some(CursorPos { line: ln, column: col }); + } + } + None +} + +/// Save the cursor position for `path`. Any previous entry for the +/// same canonical path is replaced. Errors are returned to the +/// caller but the editor swallows them (best-effort UX feature). +pub fn save(path: &Path, pos: CursorPos) -> std::io::Result<()> { + let fp = match file_path() { + Some(p) => p, + None => return Ok(()), + }; + let key = canonical_key(path); + let mut entries: Vec<(String, CursorPos)> = Vec::new(); + if let Ok(text) = fs::read_to_string(&fp) { + for line in text.lines() { + let mut parts = line.splitn(3, '\t'); + if let (Some(k), Some(ln), Some(col)) = ( + parts.next(), + parts.next().and_then(|s| s.parse::().ok()), + parts.next().and_then(|s| s.parse::().ok()), + ) { + if k == key { + continue; + } + entries.push((k.to_string(), CursorPos { line: ln, column: col })); + } + } + } + entries.push((key, pos)); + let mut out = String::new(); + for (k, p) in &entries { + out.push_str(&format!("{}\t{}\t{}\n", k, p.line, p.column)); + } + let mut f = fs::File::create(&fp)?; + f.write_all(out.as_bytes())?; + Ok(()) +} + +fn canonical_key(path: &Path) -> String { + std::fs::canonicalize(path) + .ok() + .and_then(|p| p.to_str().map(|s| s.to_string())) + .unwrap_or_else(|| path.to_string_lossy().into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + static HOME_LOCK: Mutex<()> = Mutex::new(()); + +struct HomeGuard { + #[allow(dead_code)] + lock: std::sync::MutexGuard<'static, ()>, + prev_home: Option, + prev_xdg: Option, +} + +impl Drop for HomeGuard { + fn drop(&mut self) { + match &self.prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match &self.prev_xdg { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } +} + +fn scoped_home(dir: &std::path::Path) -> HomeGuard { + let lock = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev_home = std::env::var_os("HOME"); + let prev_xdg = std::env::var_os("XDG_CONFIG_HOME"); + std::env::set_var("HOME", dir); + std::env::remove_var("XDG_CONFIG_HOME"); + HomeGuard { lock, prev_home, prev_xdg } +} + + #[test] + fn save_and_load_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("hello.txt"); + std::fs::write(&file, "hi\n").unwrap(); + let pos = CursorPos { line: 7, column: 3 }; + let _g = scoped_home(dir.path()); + save(&file, pos).unwrap(); + assert_eq!(load(&file), Some(pos)); + } + + #[test] + fn save_replaces_existing_entry() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("hello.txt"); + std::fs::write(&file, "hi\n").unwrap(); + let _g = scoped_home(dir.path()); + save(&file, CursorPos { line: 1, column: 0 }).unwrap(); + save(&file, CursorPos { line: 9, column: 4 }).unwrap(); + assert_eq!(load(&file), Some(CursorPos { line: 9, column: 4 })); + } + + #[test] + fn load_missing_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("does_not_exist.txt"); + let _g = scoped_home(dir.path()); + assert_eq!(load(&file), None); + } +} \ No newline at end of file diff --git a/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs b/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs index 1d469c488e..381f259126 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs @@ -253,7 +253,9 @@ impl FileManager { self.status.set_message("edit: no path selected"); return Ok(()); } - self.editor = Some(crate::editor::Editor::open(&p)); + let mut ed = crate::editor::Editor::open(&p); + let _ = ed.restore_cursor_position(); + self.editor = Some(ed); self.status.set_message(format!("Editing {}", p.display())); Ok(()) } @@ -286,7 +288,8 @@ impl FileManager { return Ok(()); } match crate::viewer::Viewer::open(&p) { - Ok(v) => { + Ok(mut v) => { + let _ = v.restore_cursor_position(); self.viewer = Some(v); self.status.set_message(format!("Viewing {}", p.display())); } @@ -574,6 +577,8 @@ impl FileManager { Ok(mut v) => { if let Some(ln) = line { v.jump_to_line(ln); + } else { + let _ = v.restore_cursor_position(); } self.viewer = Some(v); self.status @@ -585,6 +590,8 @@ impl FileManager { let mut ed = crate::editor::Editor::open(&path); if let Some(ln) = line { ed.goto_line(ln as u32); + } else { + let _ = ed.restore_cursor_position(); } self.editor = Some(ed); self.status diff --git a/local/recipes/tui/tlc/source/src/filemanager/mod.rs b/local/recipes/tui/tlc/source/src/filemanager/mod.rs index d4b84374a5..52383c2c1b 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mod.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mod.rs @@ -529,12 +529,16 @@ impl FileManager { } false } - EditorResult::Close => true, + EditorResult::Close => { + ed.save_cursor_position(); + true + } EditorResult::SaveThenClose => { if let Err(e) = ed.save() { self.status.set_message(format!("save: {e}")); return false; } + ed.save_cursor_position(); true } EditorResult::DiscardThenClose => true, @@ -548,7 +552,11 @@ impl FileManager { /// viewer was closed. pub fn handle_viewer_key(&mut self, key: Key) -> bool { if let Some(v) = &mut self.viewer { - v.handle_key(key) + let closed = v.handle_key(key); + if closed { + v.save_cursor_position(); + } + closed } else { false } diff --git a/local/recipes/tui/tlc/source/src/viewer/mod.rs b/local/recipes/tui/tlc/source/src/viewer/mod.rs index 7e8c811813..f692792dfc 100644 --- a/local/recipes/tui/tlc/source/src/viewer/mod.rs +++ b/local/recipes/tui/tlc/source/src/viewer/mod.rs @@ -263,6 +263,27 @@ impl Viewer { } } + /// Restore viewer scroll position from the filepos database. + pub fn restore_cursor_position(&mut self) -> bool { + let Some(pos) = crate::editor::filepos::load(&self.path) else { + return false; + }; + if pos.line >= 1 { + self.jump_to_line(pos.line as u64); + return true; + } + false + } + + /// Save the current viewer top line to the filepos database. + pub fn save_cursor_position(&self) { + let pos = crate::editor::filepos::CursorPos { + line: self.current_line() as u32, + column: 0, + }; + let _ = crate::editor::filepos::save(&self.path, pos); + } + fn current_line(&self) -> u64 { self.goto .resolve(self.cursor, goto::GotoKind::Offset) @@ -775,6 +796,8 @@ pub fn open_file(file: &str, start_line: Option) -> Result<()> { let mut v = Viewer::open(file)?; if let Some(line) = start_line { v.jump_to_line(line); + } else { + let _ = v.restore_cursor_position(); } let mut tui = crate::terminal::Tui::new()?; @@ -802,6 +825,7 @@ pub fn open_file(file: &str, start_line: Option) -> Result<()> { let key = translate_key(tk); if v.handle_key(key) { + v.save_cursor_position(); break; } } diff --git a/local/recipes/tui/tlc/source/src/widget/buttonbar.rs b/local/recipes/tui/tlc/source/src/widget/buttonbar.rs index e0b9220d11..108fc7430d 100644 --- a/local/recipes/tui/tlc/source/src/widget/buttonbar.rs +++ b/local/recipes/tui/tlc/source/src/widget/buttonbar.rs @@ -1,3 +1,6 @@ +//! MC-style function-key buttonbar rendered along the bottom of the +//! editor and viewer. + use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; @@ -7,6 +10,9 @@ use ratatui::Frame; use crate::terminal::color::Theme; use crate::terminal::mc_skin; +/// Render the F1..F10 buttonbar into `area` (one row). `labels` +/// carries ten `(number, label)` pairs in F-key order. Empty label +/// strings produce an F-key with no description, matching MC. pub fn render_buttonbar( frame: &mut Frame, area: Rect,