use crate::components::{
ActionButton, ActionRole, ActionShape, ActionState, Box as RnkBox, InteractionMode,
InteractionOutcome, Text, Theme, get_theme,
};
use crate::core::{AccessibilityProps, AccessibilityRole, Color, Element, FlexDirection};
#[derive(Debug, Clone)]
pub struct ConfirmState {
prompt: String,
focused_yes: bool,
result: Option<bool>,
default: bool,
}
impl ConfirmState {
pub fn new(prompt: impl Into<String>) -> Self {
Self {
prompt: prompt.into(),
focused_yes: false, result: None,
default: false,
}
}
pub fn default_yes(prompt: impl Into<String>) -> Self {
Self {
prompt: prompt.into(),
focused_yes: true,
result: None,
default: true,
}
}
pub fn default_no(prompt: impl Into<String>) -> Self {
Self::new(prompt)
}
pub fn prompt(&self) -> &str {
&self.prompt
}
pub fn set_prompt(&mut self, prompt: impl Into<String>) {
self.prompt = prompt.into();
}
pub fn is_yes_focused(&self) -> bool {
self.focused_yes
}
pub fn is_no_focused(&self) -> bool {
!self.focused_yes
}
pub fn focus_yes(&mut self) {
self.focused_yes = true;
}
pub fn focus_no(&mut self) {
self.focused_yes = false;
}
pub fn toggle_focus(&mut self) {
self.focused_yes = !self.focused_yes;
}
pub fn confirm(&mut self) {
self.result = Some(true);
}
pub fn cancel(&mut self) {
self.result = Some(false);
}
pub fn submit(&mut self) {
self.result = Some(self.focused_yes);
}
pub fn result(&self) -> Option<bool> {
self.result
}
pub fn is_confirmed(&self) -> bool {
self.result == Some(true)
}
pub fn is_cancelled(&self) -> bool {
self.result == Some(false)
}
pub fn is_answered(&self) -> bool {
self.result.is_some()
}
pub fn reset(&mut self) {
self.result = None;
self.focused_yes = self.default;
}
pub fn default(&self) -> bool {
self.default
}
}
#[derive(Debug, Clone)]
pub struct ConfirmStyle {
pub yes_label: String,
pub no_label: String,
pub separator: String,
pub focused_color: Option<Color>,
pub focused_bg: Option<Color>,
pub unfocused_color: Option<Color>,
pub prompt_color: Option<Color>,
pub show_hints: bool,
pub button_style: ButtonStyle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ButtonStyle {
#[default]
Brackets,
Angles,
Parens,
Plain,
Padded,
}
impl Default for ConfirmStyle {
fn default() -> Self {
Self::from_theme(&Theme::dark())
}
}
impl From<ButtonStyle> for ActionShape {
fn from(style: ButtonStyle) -> Self {
match style {
ButtonStyle::Brackets => ActionShape::Brackets,
ButtonStyle::Angles => ActionShape::Angles,
ButtonStyle::Parens => ActionShape::Parens,
ButtonStyle::Plain => ActionShape::Plain,
ButtonStyle::Padded => ActionShape::Padded,
}
}
}
impl ConfirmStyle {
pub fn from_theme(theme: &Theme) -> Self {
let tokens = theme.design_tokens();
let focused = theme.action_style(ActionRole::Primary, ActionState::Focused);
let rest = theme.action_style(ActionRole::Secondary, ActionState::Rest);
Self {
yes_label: "Yes".to_string(),
no_label: "No".to_string(),
separator: " ".repeat(tokens.density.gap as usize),
focused_color: Some(focused.fg),
focused_bg: focused.bg,
unfocused_color: Some(rest.fg),
prompt_color: Some(theme.text.primary),
show_hints: true,
button_style: ButtonStyle::Brackets,
}
}
pub fn new() -> Self {
Self::default()
}
pub fn yes_label(mut self, label: impl Into<String>) -> Self {
self.yes_label = label.into();
self
}
pub fn no_label(mut self, label: impl Into<String>) -> Self {
self.no_label = label.into();
self
}
pub fn labels(mut self, yes: impl Into<String>, no: impl Into<String>) -> Self {
self.yes_label = yes.into();
self.no_label = no.into();
self
}
pub fn separator(mut self, sep: impl Into<String>) -> Self {
self.separator = sep.into();
self
}
pub fn focused_color(mut self, color: Color) -> Self {
self.focused_color = Some(color);
self
}
pub fn focused_bg(mut self, color: Color) -> Self {
self.focused_bg = Some(color);
self
}
pub fn unfocused_color(mut self, color: Color) -> Self {
self.unfocused_color = Some(color);
self
}
pub fn prompt_color(mut self, color: Color) -> Self {
self.prompt_color = Some(color);
self
}
pub fn show_hints(mut self, show: bool) -> Self {
self.show_hints = show;
self
}
pub fn button_style(mut self, style: ButtonStyle) -> Self {
self.button_style = style;
self
}
fn format_button(&self, label: &str, hint: Option<char>) -> String {
ActionShape::from(self.button_style).format_label(label, hint, self.show_hints)
}
fn action_text(&self, label: &str, hint: char, role: ActionRole, state: ActionState) -> Text {
let mut text = ActionButton::new(label)
.role(role)
.state(state)
.shape(self.button_style.into())
.hint(Some(hint))
.show_hint(self.show_hints)
.into_text();
if state == ActionState::Focused {
if let Some(color) = self.focused_color {
text = text.color(color);
}
if let Some(bg) = self.focused_bg {
text = text.background(bg);
}
text = text.bold();
} else if let Some(color) = self.unfocused_color {
text = text.color(color);
}
text
}
pub fn confirm_cancel() -> Self {
Self::default().labels("Confirm", "Cancel")
}
pub fn ok_cancel() -> Self {
Self::default().labels("OK", "Cancel")
}
pub fn save_discard() -> Self {
Self::default().labels("Save", "Discard")
}
pub fn delete_keep() -> Self {
Self::default().labels("Delete", "Keep")
}
}
#[derive(Debug, Clone)]
pub struct Confirm<'a> {
state: &'a ConfirmState,
style: ConfirmStyle,
focused: bool,
}
impl<'a> Confirm<'a> {
pub fn new(state: &'a ConfirmState) -> Self {
Self {
state,
style: ConfirmStyle::from_theme(&get_theme()),
focused: true,
}
}
pub fn style(mut self, style: ConfirmStyle) -> Self {
self.style = style;
self
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn yes_label(mut self, label: impl Into<String>) -> Self {
self.style.yes_label = label.into();
self
}
pub fn no_label(mut self, label: impl Into<String>) -> Self {
self.style.no_label = label.into();
self
}
pub fn render(&self) -> String {
let yes_btn = self.style.format_button(&self.style.yes_label, Some('Y'));
let no_btn = self.style.format_button(&self.style.no_label, Some('N'));
format!(
"{} {}{}{}",
self.state.prompt, yes_btn, self.style.separator, no_btn
)
}
pub fn into_element(self) -> Element {
let mut container = RnkBox::new().flex_direction(FlexDirection::Column);
let mut prompt_text = Text::new(&self.state.prompt);
if let Some(color) = self.style.prompt_color {
prompt_text = prompt_text.color(color);
}
container = container.child(prompt_text.into_element());
let mut buttons = RnkBox::new().flex_direction(FlexDirection::Row);
let yes_state = if self.focused && self.state.is_yes_focused() {
ActionState::Focused
} else {
ActionState::Rest
};
let yes_text =
self.style
.action_text(&self.style.yes_label, 'Y', ActionRole::Primary, yes_state);
buttons = buttons.child(yes_text.into_element());
buttons = buttons.child(Text::new(&self.style.separator).into_element());
let no_state = if self.focused && self.state.is_no_focused() {
ActionState::Focused
} else {
ActionState::Rest
};
let no_text =
self.style
.action_text(&self.style.no_label, 'N', ActionRole::Secondary, no_state);
buttons = buttons.child(no_text.into_element());
container = container.child(buttons.into_element());
container.into_element().with_accessibility(
AccessibilityProps::new(AccessibilityRole::Dialog)
.label(self.state.prompt.clone())
.description(format!(
"{} or {}",
self.style.yes_label, self.style.no_label
))
.focusable(self.focused),
)
}
}
pub fn handle_confirm_input(
state: &mut ConfirmState,
input: &str,
key: &crate::hooks::Key,
) -> bool {
handle_confirm_input_with_mode(state, input, key, InteractionMode::Enabled).is_handled()
}
pub fn handle_confirm_input_with_mode(
state: &mut ConfirmState,
input: &str,
key: &crate::hooks::Key,
mode: InteractionMode,
) -> InteractionOutcome<bool> {
if mode.is_disabled() {
return InteractionOutcome::Ignored;
}
if state.is_answered() {
return InteractionOutcome::Ignored;
}
if key.tab || key.left_arrow || key.right_arrow {
state.toggle_focus();
return InteractionOutcome::Handled;
}
if key.escape || input.eq_ignore_ascii_case("n") {
if mode.is_read_only() {
return InteractionOutcome::Cancelled;
}
state.cancel();
return InteractionOutcome::Cancelled;
}
if mode.is_read_only() {
return InteractionOutcome::Ignored;
}
if key.return_key || key.space {
state.submit();
return InteractionOutcome::Submitted(state.result().unwrap_or(false));
}
if input.eq_ignore_ascii_case("y") {
state.confirm();
return InteractionOutcome::Submitted(true);
}
InteractionOutcome::Ignored
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_confirm_state_new() {
let state = ConfirmState::new("Delete?");
assert_eq!(state.prompt(), "Delete?");
assert!(!state.is_yes_focused());
assert!(state.is_no_focused());
assert!(!state.is_answered());
}
#[test]
fn test_confirm_state_default_yes() {
let state = ConfirmState::default_yes("Continue?");
assert!(state.is_yes_focused());
assert!(!state.is_no_focused());
}
#[test]
fn test_confirm_state_toggle() {
let mut state = ConfirmState::new("Test?");
assert!(state.is_no_focused());
state.toggle_focus();
assert!(state.is_yes_focused());
state.toggle_focus();
assert!(state.is_no_focused());
}
#[test]
fn test_confirm_state_confirm() {
let mut state = ConfirmState::new("Test?");
state.confirm();
assert!(state.is_answered());
assert!(state.is_confirmed());
assert!(!state.is_cancelled());
assert_eq!(state.result(), Some(true));
}
#[test]
fn test_confirm_state_cancel() {
let mut state = ConfirmState::new("Test?");
state.cancel();
assert!(state.is_answered());
assert!(!state.is_confirmed());
assert!(state.is_cancelled());
assert_eq!(state.result(), Some(false));
}
#[test]
fn test_confirm_state_submit() {
let mut state = ConfirmState::new("Test?");
state.focus_yes();
state.submit();
assert!(state.is_confirmed());
let mut state = ConfirmState::new("Test?");
state.focus_no();
state.submit();
assert!(state.is_cancelled());
}
#[test]
fn test_confirm_state_reset() {
let mut state = ConfirmState::default_yes("Test?");
state.focus_no();
state.confirm();
state.reset();
assert!(!state.is_answered());
assert!(state.is_yes_focused()); }
#[test]
fn test_confirm_style_presets() {
let _default = ConfirmStyle::default();
let _confirm_cancel = ConfirmStyle::confirm_cancel();
let _ok_cancel = ConfirmStyle::ok_cancel();
let _save_discard = ConfirmStyle::save_discard();
let _delete_keep = ConfirmStyle::delete_keep();
}
#[test]
fn test_confirm_style_from_theme() {
let theme = Theme::dark();
let style = ConfirmStyle::from_theme(&theme);
let expected = theme.action_style(ActionRole::Primary, ActionState::Focused);
assert_eq!(style.separator, " ");
assert_eq!(style.focused_color, Some(expected.fg));
assert_eq!(style.focused_bg, expected.bg);
assert_eq!(style.prompt_color, Some(theme.text.primary));
}
#[test]
fn test_confirm_render() {
let state = ConfirmState::new("Delete file?");
let confirm = Confirm::new(&state);
let rendered = confirm.render();
assert!(rendered.contains("Delete file?"));
assert!(rendered.contains("Yes"));
assert!(rendered.contains("No"));
}
#[test]
fn test_handle_confirm_input_with_mode() {
let mut state = ConfirmState::new("Delete?");
let outcome = handle_confirm_input_with_mode(
&mut state,
"",
&crate::hooks::Key {
tab: true,
..Default::default()
},
InteractionMode::ReadOnly,
);
assert_eq!(outcome, InteractionOutcome::Handled);
assert!(state.is_yes_focused());
let outcome = handle_confirm_input_with_mode(
&mut state,
"y",
&crate::hooks::Key::default(),
InteractionMode::ReadOnly,
);
assert_eq!(outcome, InteractionOutcome::Ignored);
assert!(!state.is_answered());
let outcome = handle_confirm_input_with_mode(
&mut state,
"y",
&crate::hooks::Key::default(),
InteractionMode::Enabled,
);
assert_eq!(outcome, InteractionOutcome::Submitted(true));
assert!(state.is_confirmed());
}
}