use std::any::Any;
use crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::Frame;
use super::chrome::ModalChrome;
use super::types::{Modal, ModalKind, ModalOutcome, ModalRenderCtx};
use crate::app::tips::Tip;
use crate::app::App;
use crate::config::Theme;
use crate::ui::modal::LinkableResponse;
use crate::ui::{
controls, ModalButton, ModalLink, ModalLinkTarget, ModalResponse, PROSE_CONTENT_WIDTH,
};
const DISABLE_BUTTON: usize = 1;
pub struct DailyTipModal {
tip: &'static Tip,
buttons: Vec<ModalButton>,
links: Vec<ModalLink>,
chrome: ModalChrome,
}
impl DailyTipModal {
pub fn new(tip: &'static Tip) -> Self {
Self::build(tip, true)
}
pub fn browsing(tip: &'static Tip) -> Self {
Self::build(tip, false)
}
fn build(tip: &'static Tip, show_disable: bool) -> Self {
let mut buttons = vec![ModalButton::new("Got it")];
if show_disable {
buttons.push(ModalButton::new("Don't show tips"));
}
Self {
tip,
buttons,
links: Vec::new(),
chrome: ModalChrome::new(ModalKind::Normal, true)
.with_max_content_width(PROSE_CONTENT_WIDTH),
}
}
fn append_learn_more(
&self,
body: &mut Vec<Line<'static>>,
focused_link: Option<usize>,
theme: &Theme,
) -> Vec<ModalLink> {
let Some(link) = self.tip.link.as_ref() else {
return Vec::new();
};
let title = link.doc.title();
body.push(Line::raw(""));
let line_idx = body.len();
body.push(Line::from(vec![
Span::raw("See "),
Span::styled(title, controls::link_style(focused_link == Some(0), theme)),
Span::raw(" for more info."),
]));
let target = ModalLinkTarget {
id: link.doc,
fragment: link.fragment,
};
vec![ModalLink::new(line_idx, 1, target, title)]
}
fn close_with(&self, disable: bool, link: Option<ModalLinkTarget>) -> ModalOutcome {
ModalOutcome::CloseAnd(Box::new(move |app| {
if disable {
app.config.editor.daily_tips = false;
app.save_config_with_flash("failed to persist daily-tips preference");
}
if let Some(target) = link {
app.follow_modal_link(target);
}
}))
}
fn resolve_linkable(&self, response: LinkableResponse) -> ModalOutcome {
match response {
LinkableResponse::Modal(r) => self.resolve(r),
LinkableResponse::Link(idx) => match self.links.get(idx) {
Some(link) => self.close_with(false, Some(link.target.clone())),
None => ModalOutcome::Continue,
},
}
}
fn resolve(&self, response: ModalResponse) -> ModalOutcome {
match response {
ModalResponse::Continue => ModalOutcome::Continue,
ModalResponse::ButtonPressed(DISABLE_BUTTON) => self.close_with(true, None),
ModalResponse::Cancelled | ModalResponse::ButtonPressed(_) => {
self.close_with(false, None)
}
}
}
}
impl Modal for DailyTipModal {
fn render(&mut self, frame: &mut Frame<'_>, area: Rect, ctx: &ModalRenderCtx<'_>) {
let mut body: Vec<Line<'static>> = vec![Line::raw(self.tip.text)];
self.links = self.append_learn_more(&mut body, self.chrome.focused_link(), ctx.theme);
self.chrome
.render_with_links(frame, area, ctx, "Tip", &body, &self.buttons, &self.links);
}
fn handle_key(
&mut self,
key: KeyEvent,
_app: &mut App,
_doc_height: usize,
_doc_width: usize,
) -> ModalOutcome {
let response = self
.chrome
.on_key_linkable(&key, self.links.len(), self.buttons.len());
self.resolve_linkable(response)
}
fn handle_wheel(&mut self, delta: i32) {
self.chrome.on_wheel(delta);
}
fn handle_click(&mut self, col: u16, row: u16, _app: &mut App) -> ModalOutcome {
let response = self.chrome.on_click_linkable(col, row);
self.resolve_linkable(response)
}
fn kind(&self) -> ModalKind {
self.chrome.kind()
}
fn dismissable(&self) -> bool {
self.chrome.dismissable()
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::tips::ALL_TIPS;
fn linked_tip() -> &'static Tip {
ALL_TIPS
.iter()
.find(|t| t.link.is_some())
.expect("a linked tip exists")
}
fn bare_tip() -> &'static Tip {
ALL_TIPS
.iter()
.find(|t| t.link.is_none())
.expect("a link-less tip exists")
}
#[test]
fn button_indices_match_their_positions() {
let modal = DailyTipModal::new(linked_tip());
assert_eq!(modal.buttons[0].label, "Got it");
assert_eq!(modal.buttons[DISABLE_BUTTON].label, "Don't show tips");
assert_eq!(modal.buttons.len(), 2);
}
#[test]
fn a_browsed_tip_omits_the_off_switch() {
let modal = DailyTipModal::browsing(linked_tip());
assert_eq!(modal.buttons.len(), 1);
assert_eq!(modal.buttons[0].label, "Got it");
}
#[test]
fn a_linked_tip_appends_a_see_more_footnote_and_a_bare_one_does_not() {
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let mut body = Vec::new();
let links = DailyTipModal::new(linked_tip()).append_learn_more(&mut body, None, theme);
assert_eq!(
links.len(),
1,
"a linked tip gets exactly one footnote link"
);
let footnote: String = body
.last()
.unwrap()
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(
footnote,
format!(
"See {} for more info.",
linked_tip().link.as_ref().unwrap().doc.title()
),
);
assert_eq!(links[0].span_idx, 1, "the title span, after `See `");
let mut body = Vec::new();
let links = DailyTipModal::new(bare_tip()).append_learn_more(&mut body, None, theme);
assert!(links.is_empty(), "a self-contained tip adds no footnote");
assert!(body.is_empty());
}
#[test]
fn the_disable_button_turns_daily_tips_off() {
let _iso = crate::test_env::config_isolation();
let mut app = crate::app::test_utils::make_app();
assert!(app.config.editor.daily_tips, "on by default");
let modal = DailyTipModal::new(bare_tip());
let outcome = modal.resolve(ModalResponse::ButtonPressed(DISABLE_BUTTON));
match outcome {
ModalOutcome::CloseAnd(f) => f(&mut app),
_ => panic!("the off switch closes the modal"),
}
assert!(!app.config.editor.daily_tips);
}
#[test]
fn got_it_closes_without_changing_the_setting() {
let _iso = crate::test_env::config_isolation();
let mut app = crate::app::test_utils::make_app();
let modal = DailyTipModal::new(bare_tip());
let outcome = modal.resolve(ModalResponse::ButtonPressed(0));
match outcome {
ModalOutcome::CloseAnd(f) => f(&mut app),
_ => panic!("Got it closes"),
}
assert!(app.config.editor.daily_tips, "unchanged");
}
}
#[cfg(test)]
mod click_tests {
use super::*;
use crate::app::test_utils::make_app;
use crate::app::tips::ALL_TIPS;
use crate::config::{Config, Theme};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
#[test]
fn clicking_a_tips_footnote_opens_that_manual_page() {
let _iso = crate::test_env::config_isolation();
let mut app = make_app();
let tip = ALL_TIPS
.iter()
.find(|t| t.link.is_some())
.expect("a linked tip exists");
let expected = tip.link.as_ref().unwrap().doc;
let mut modal = DailyTipModal::new(tip);
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let config = Config::default();
let mut term = Terminal::new(TestBackend::new(100, 40)).unwrap();
term.draw(|frame| {
let ctx = ModalRenderCtx {
theme,
config: &config,
cursor_visible: false,
};
let area = frame.area();
modal.render(frame, area, &ctx);
})
.unwrap();
let (_, rect) = *modal
.chrome
.state
.link_rects
.first()
.expect("the rendered body records a rect for the footnote link");
match modal.handle_click(rect.x, rect.y, &mut app) {
ModalOutcome::CloseAnd(f) => f(&mut app),
_ => panic!("a footnote click closes the tip and follows the link"),
}
assert_eq!(
app.open_doc,
Some(expected),
"opened the page the link named"
);
}
}