use 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, duration: Duration) { self.mode = StatusMode::TimedMessage { text: text.into(), expires_at: Instant::now() + duration, }; } pub fn set_dismissible(&mut self, text: impl Into) { 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" ); } }