use std::fmt;
use ratatui::{
Frame,
layout::Rect,
style::Style,
widgets::{Block, Borders},
};
use tui_textarea::{CursorMove, CursorRenderMode, TextArea, WrapMode};
use super::theme::MissionControlTheme;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SingleLineDisplay {
Plain,
Redacted,
}
#[derive(Clone)]
pub(crate) struct SingleLineField {
textarea: TextArea<'static>,
display: SingleLineDisplay,
}
impl SingleLineField {
pub(crate) fn plain() -> Self {
Self::new(SingleLineDisplay::Plain)
}
pub(crate) fn redacted() -> Self {
Self::new(SingleLineDisplay::Redacted)
}
fn new(display: SingleLineDisplay) -> Self {
Self {
textarea: configured_textarea(String::new()),
display,
}
}
pub(crate) fn value(&self) -> &str {
self.textarea.lines().first().map_or("", String::as_str)
}
pub(crate) fn is_empty(&self) -> bool {
self.value().is_empty()
}
pub(crate) fn set_value(&mut self, value: impl AsRef<str>) {
let normalized = normalize_single_line(value.as_ref());
self.textarea = configured_textarea(normalized);
self.textarea.move_cursor(CursorMove::End);
}
pub(crate) fn insert_char(&mut self, ch: char) -> bool {
let mut encoded = [0; 4];
self.insert_str(ch.encode_utf8(&mut encoded))
}
pub(crate) fn insert_str(&mut self, value: &str) -> bool {
let normalized = normalize_single_line(value);
!normalized.is_empty() && self.textarea.insert_str(normalized)
}
pub(crate) fn backspace(&mut self) -> bool {
self.textarea.delete_char()
}
pub(crate) fn delete_forward(&mut self) -> bool {
self.textarea.delete_next_char()
}
pub(crate) fn move_left(&mut self) {
self.textarea.move_cursor(CursorMove::Back);
}
pub(crate) fn move_right(&mut self) {
self.textarea.move_cursor(CursorMove::Forward);
}
pub(crate) fn move_home(&mut self) {
self.textarea.move_cursor(CursorMove::Head);
}
pub(crate) fn move_end(&mut self) {
self.textarea.move_cursor(CursorMove::End);
}
pub(crate) fn clear(&mut self) {
self.textarea = configured_textarea(String::new());
}
pub(crate) fn take(&mut self) -> Option<String> {
if self.value().trim().is_empty() {
return None;
}
let value = self.value().to_string();
self.clear();
Some(value)
}
pub(crate) fn render(
&self,
frame: &mut Frame<'_>,
area: Rect,
label: &str,
placeholder: &str,
focused: bool,
theme: MissionControlTheme,
) {
if area.is_empty() {
return;
}
let title = if focused {
format!(" {label} • editing ")
} else {
format!(" {label} ")
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(theme.border(focused))
.style(theme.panel_alt());
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.is_empty() {
return;
}
let mut projection = self.render_projection();
projection.set_style(theme.panel_alt());
projection.set_cursor_line_style(Style::default());
projection.set_cursor_render_mode(CursorRenderMode::Hidden);
projection.set_placeholder_text(placeholder);
projection.set_placeholder_style(theme.muted(false));
frame.render_widget(&projection, inner);
if focused && let Some(position) = projection.rendered_cursor_position() {
frame.set_cursor_position(position);
}
}
fn render_projection(&self) -> TextArea<'static> {
let cursor_col = self.textarea.cursor().1;
let display = match self.display {
SingleLineDisplay::Plain => self.value().to_string(),
SingleLineDisplay::Redacted => "•".repeat(self.value().chars().count()),
};
let mut projection = configured_textarea(display);
for _ in 0..cursor_col {
projection.move_cursor(CursorMove::Forward);
}
projection
}
#[cfg(test)]
pub(crate) fn cursor(&self) -> usize {
self.textarea.cursor().1
}
}
impl Default for SingleLineField {
fn default() -> Self {
Self::plain()
}
}
impl fmt::Debug for SingleLineField {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let value = match self.display {
SingleLineDisplay::Plain => self.value(),
SingleLineDisplay::Redacted if self.is_empty() => "<empty>",
SingleLineDisplay::Redacted => "<redacted>",
};
formatter
.debug_struct("SingleLineField")
.field("value", &value)
.field("cursor", &self.textarea.cursor().1)
.field("display", &self.display)
.finish()
}
}
impl PartialEq for SingleLineField {
fn eq(&self, other: &Self) -> bool {
self.value() == other.value()
&& self.textarea.cursor() == other.textarea.cursor()
&& self.display == other.display
}
}
impl PartialEq<&str> for SingleLineField {
fn eq(&self, other: &&str) -> bool {
self.value() == *other
}
}
impl PartialEq<String> for SingleLineField {
fn eq(&self, other: &String) -> bool {
self.value() == other
}
}
impl Eq for SingleLineField {}
fn configured_textarea(value: String) -> TextArea<'static> {
let mut textarea = TextArea::from([value]);
textarea.set_wrap_mode(WrapMode::None);
textarea.set_cursor_render_mode(CursorRenderMode::Hidden);
textarea
}
fn normalize_single_line(value: &str) -> String {
let mut normalized = String::with_capacity(value.len());
let mut previous_was_cr = false;
for ch in value.chars() {
match ch {
'\r' => {
normalized.push(' ');
previous_was_cr = true;
}
'\n' if previous_was_cr => {
previous_was_cr = false;
}
'\n' | '\t' => {
normalized.push(' ');
previous_was_cr = false;
}
ch if ch.is_control() => {
previous_was_cr = false;
}
ch => {
normalized.push(ch);
previous_was_cr = false;
}
}
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{Terminal, backend::TestBackend};
fn rendered(backend: &TestBackend) -> String {
backend
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect()
}
#[test]
fn edits_at_beginning_middle_and_end_with_unicode() {
let mut field = SingleLineField::plain();
field.insert_str("a界c");
field.move_left();
field.insert_char('🙂');
assert_eq!(field.value(), "a界🙂c");
assert_eq!(field.cursor(), 3);
field.move_home();
field.delete_forward();
field.move_end();
field.backspace();
assert_eq!(field.value(), "界🙂");
assert_eq!(field.cursor(), 2);
}
#[test]
fn paste_normalization_never_creates_an_extra_line() {
let mut field = SingleLineField::plain();
field.insert_str("first\r\nsecond\nthird\rfourth\tend\0");
assert_eq!(field.value(), "first second third fourth end");
assert_eq!(field.textarea.lines().len(), 1);
}
#[test]
fn long_wide_value_scrolls_and_keeps_cursor_visible() {
let mut field = SingleLineField::plain();
field.insert_str("prefix-界界界界界界界界界界-suffix");
let backend = TestBackend::new(18, 3);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
field.render(
frame,
frame.area(),
"Value",
"placeholder",
true,
MissionControlTheme::default(),
);
})
.unwrap();
let output = rendered(terminal.backend());
assert!(output.contains("suffix"));
assert!(!output.contains("prefix"));
}
#[test]
fn redacted_projection_and_debug_never_expose_raw_value() {
let secret = "https://localhost/callback?code=top-secret";
let mut field = SingleLineField::redacted();
field.insert_str(secret);
field.move_left();
let backend = TestBackend::new(30, 3);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
field.render(
frame,
frame.area(),
"OAuth fallback",
"paste redirect or code",
true,
MissionControlTheme::default(),
);
})
.unwrap();
let output = rendered(terminal.backend());
assert!(!output.contains(secret));
assert!(output.contains('•'));
assert!(!format!("{field:?}").contains("top-secret"));
}
#[test]
fn focused_and_unfocused_styles_are_distinct() {
let field = SingleLineField::plain();
let backend = TestBackend::new(24, 6);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
field.render(
frame,
Rect::new(0, 0, 24, 3),
"Focused",
"",
true,
MissionControlTheme::default(),
);
field.render(
frame,
Rect::new(0, 3, 24, 3),
"Unfocused",
"",
false,
MissionControlTheme::default(),
);
})
.unwrap();
let output = rendered(terminal.backend());
assert!(output.contains("Focused • editing"));
assert!(output.contains("Unfocused"));
assert_ne!(
terminal.backend().buffer()[(0, 0)].fg,
terminal.backend().buffer()[(0, 3)].fg
);
}
}