Yet Another Markdown Editor! TUI for READMEs and friends.
editor markdown readme development-tool
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335use std::time::{Duration, Instant};
/// Operating mode of the status bar.#[derive(Debug, Default)]pub enum StatusMode { #[default] Normal, TimedMessage { text: String, expires_at: Instant, }, DismissibleMessage(String), ExitPrompt, /// Active go-to-line prompt. `input` accumulates the digits the user types; /// the prompt is confirmed with Enter or cancelled with Esc. GoToLine { input: String, }, /// Confirmation prompt shown by Ctrl+O when the current buffer is dirty. /// /// The user must choose to discard unsaved changes before the file picker /// opens. [Y] proceeds to the picker; [N] / Esc cancels. SwitchFilePrompt, /// Save-as prompt for untitled buffers. `input` accumulates the filename /// the user types; Enter confirms; Esc cancels. When `then_exit` is true /// the editor exits immediately after a successful save (triggered by the /// "save & exit" path of the exit prompt). SaveAs { input: String, then_exit: bool, },}
/// All mutable state for the status bar / hint line.#[derive(Debug, Default)]pub struct StatusLine { pub mode: StatusMode,}
impl StatusLine { pub fn set_timed(&mut self, text: impl Into<String>, duration: Duration) { self.mode = StatusMode::TimedMessage { text: text.into(), expires_at: Instant::now() + duration, }; }
pub fn set_dismissible(&mut self, text: impl Into<String>) { self.mode = StatusMode::DismissibleMessage(text.into()); }
pub fn dismiss(&mut self) { if matches!(self.mode, StatusMode::DismissibleMessage(_)) { self.mode = StatusMode::Normal; } }
/// Enter save-as prompt mode with an empty filename buffer. /// /// `then_exit` — when true, the editor exits immediately after a successful /// save (used when save-as is triggered by the "save & exit" exit-prompt path). pub fn start_save_as(&mut self, then_exit: bool) { self.mode = StatusMode::SaveAs { input: String::new(), then_exit, }; }
/// Append a character to the save-as filename buffer. /// The buffer is capped at 255 characters (longest filename on most filesystems). pub fn save_as_push(&mut self, c: char) { if let StatusMode::SaveAs { input, .. } = &mut self.mode && input.len() < 255 { input.push(c); } }
/// Remove the last character from the save-as filename buffer. pub fn save_as_pop(&mut self) { if let StatusMode::SaveAs { input, .. } = &mut self.mode { input.pop(); } }
/// Return a snapshot of the current save-as state, if in that mode. /// /// Returns `(input, then_exit)` — the current filename buffer and whether /// to exit after saving. pub fn save_as_state(&self) -> Option<(&str, bool)> { if let StatusMode::SaveAs { input, then_exit } = &self.mode { Some((input, *then_exit)) } else { None } }
/// Enter go-to-line prompt mode with an empty input buffer. pub fn start_goto_line(&mut self) { self.mode = StatusMode::GoToLine { input: String::new(), }; }
/// Append a digit character to the go-to-line input buffer. /// Only digits are accepted; call sites are responsible for filtering. /// The buffer is capped at 7 characters (covers up to line 9,999,999). pub fn goto_push(&mut self, c: char) { if let StatusMode::GoToLine { input } = &mut self.mode && input.len() < 7 { input.push(c); } }
/// Remove the last character from the go-to-line input buffer. pub fn goto_pop(&mut self) { if let StatusMode::GoToLine { input } = &mut self.mode { input.pop(); } }
/// Return the current go-to-line input, if we are in that mode. pub fn goto_input(&self) -> Option<&str> { if let StatusMode::GoToLine { input } = &self.mode { Some(input) } else { None } }
/// Clear any expired timed messages; call each frame. pub fn tick(&mut self) { if matches!(&self.mode, StatusMode::TimedMessage { expires_at, .. } if Instant::now() >= *expires_at) { self.mode = StatusMode::Normal; } }
/// Returns the current message text, if any. pub fn message(&self) -> Option<&str> { match &self.mode { StatusMode::TimedMessage { text, .. } => Some(text), StatusMode::DismissibleMessage(text) => Some(text), _ => None, } }}
// ---------------------------------------------------------------------------// Tests// ---------------------------------------------------------------------------
#[cfg(test)]mod tests { use super::*;
#[test] fn timed_message_expires() { let mut s = StatusLine::default(); s.set_timed("Saved.", Duration::from_millis(1)); std::thread::sleep(Duration::from_millis(5)); s.tick(); assert!( matches!(s.mode, StatusMode::Normal), "expected Normal after expiry" ); }
#[test] fn timed_message_not_expired_yet() { let mut s = StatusLine::default(); s.set_timed("Saving...", Duration::from_secs(60)); s.tick(); assert!( matches!(s.mode, StatusMode::TimedMessage { .. }), "should still be active" ); }
#[test] fn dismissible_clears_on_dismiss() { let mut s = StatusLine::default(); s.set_dismissible("warning"); s.dismiss(); assert!( matches!(s.mode, StatusMode::Normal), "expected Normal after dismiss" ); }
#[test] fn dismiss_noop_on_normal() { let mut s = StatusLine::default(); s.dismiss(); // should not panic assert!(matches!(s.mode, StatusMode::Normal)); }
#[test] fn message_returns_text_for_timed() { let mut s = StatusLine::default(); s.set_timed("hello", Duration::from_secs(10)); assert_eq!(s.message(), Some("hello")); }
#[test] fn message_returns_none_for_normal() { let s = StatusLine::default(); assert_eq!(s.message(), None); }
#[test] fn message_returns_none_for_exit_prompt() { let s = StatusLine { mode: StatusMode::ExitPrompt, }; assert_eq!(s.message(), None); }
#[test] fn set_dismissible_sets_dismissible_mode() { let mut s = StatusLine::default(); s.set_dismissible("oops"); assert!( matches!(s.mode, StatusMode::DismissibleMessage(_)), "set_dismissible must set DismissibleMessage mode" ); }
#[test] fn message_returns_text_for_dismissible() { let mut s = StatusLine::default(); s.set_dismissible("config error"); assert_eq!( s.message(), Some("config error"), "message() must return the DismissibleMessage text" ); }
#[test] fn start_save_as_enters_save_as_mode() { let mut s = StatusLine::default(); s.start_save_as(false); assert!( matches!( s.mode, StatusMode::SaveAs { then_exit: false, .. } ), "start_save_as(false) must set SaveAs mode with then_exit=false" ); }
#[test] fn start_save_as_then_exit_true() { let mut s = StatusLine::default(); s.start_save_as(true); assert!( matches!( s.mode, StatusMode::SaveAs { then_exit: true, .. } ), "start_save_as(true) must set then_exit=true" ); }
#[test] fn save_as_push_appends_chars() { let mut s = StatusLine::default(); s.start_save_as(false); s.save_as_push('f'); s.save_as_push('o'); s.save_as_push('o'); assert_eq!( s.save_as_state().map(|(i, _)| i), Some("foo"), "save_as_push must append characters to the input buffer" ); }
#[test] fn save_as_pop_removes_last_char() { let mut s = StatusLine::default(); s.start_save_as(false); s.save_as_push('f'); s.save_as_push('o'); s.save_as_pop(); assert_eq!( s.save_as_state().map(|(i, _)| i), Some("f"), "save_as_pop must remove the last character" ); }
#[test] fn save_as_state_none_when_not_in_save_as_mode() { let s = StatusLine::default(); assert_eq!( s.save_as_state(), None, "save_as_state must return None in Normal mode" ); }
#[test] fn save_as_push_noop_outside_save_as_mode() { let mut s = StatusLine::default(); s.save_as_push('x'); // should not panic or change mode assert!(matches!(s.mode, StatusMode::Normal)); }
#[test] fn save_as_push_caps_at_255_chars() { // Kills: `replace < with <=` in `input.len() < 255` — the mutation // would allow a 256th character through. let mut s = StatusLine::default(); s.start_save_as(false); for _ in 0..255 { s.save_as_push('a'); } s.save_as_push('z'); // must be rejected: buffer already at limit assert_eq!( s.save_as_state().map(|(i, _)| i.len()), Some(255), "save_as_push must not grow input beyond 255 chars" ); }}