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.
This commit is contained in:
2026-06-20 11:18:28 +03:00
parent 98b3ade744
commit d6aaf4e8af
5 changed files with 203 additions and 4 deletions
@@ -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<PathBuf> {
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<PathBuf> {
config_dir().map(|d| d.join("filepos"))
}
/// Load the saved cursor position for `path`, if any.
pub fn load(path: &Path) -> Option<CursorPos> {
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::<u32>().ok()?;
let col = parts.next()?.parse::<u32>().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::<u32>().ok()),
parts.next().and_then(|s| s.parse::<u32>().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<std::ffi::OsString>,
prev_xdg: Option<std::ffi::OsString>,
}
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);
}
}
@@ -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
@@ -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
}
@@ -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<u64>) -> 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<u64>) -> Result<()> {
let key = translate_key(tk);
if v.handle_key(key) {
v.save_cursor_position();
break;
}
}
@@ -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,