use std::any::Any;
use crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::Frame;
use super::chrome::ModalChrome;
use super::docs_link::DocsFootnote;
use super::types::{Modal, ModalKind, ModalOutcome, ModalRenderCtx};
use crate::app::App;
use crate::docs::DocId;
use crate::terminal::Capabilities;
use crate::ui::modal::LinkableResponse;
use crate::ui::{
build_cap_lines, theme_downgrade_lines, CapSummary, ModalButton, ModalLink, ModalLinkTarget,
ModalResponse, PROSE_CONTENT_WIDTH,
};
const ADJUST_BUTTON: usize = 0;
const DOCS_FOOTNOTE: DocsFootnote = DocsFootnote {
label: "Terminal compatibility",
target: ModalLinkTarget {
id: DocId::TerminalCompatibility,
fragment: None,
},
trailer: " lists what each capability affects and which terminals support it.",
};
struct ThemeDowngrade {
configured: String,
substituted: &'static str,
}
pub struct TerminalCapabilitiesModal {
summary: CapSummary,
fingerprint: String,
downgrade: Option<ThemeDowngrade>,
buttons: Vec<ModalButton>,
links: Vec<ModalLink>,
chrome: ModalChrome,
}
impl TerminalCapabilitiesModal {
pub fn from_capabilities(caps: &Capabilities, seen: &[String]) -> Option<Self> {
let fingerprint = caps.fingerprint();
if seen.iter().any(|s| s == &fingerprint) {
return None;
}
Some(Self {
summary: CapSummary::from_caps(caps),
fingerprint,
downgrade: None,
buttons: vec![ModalButton::new("Adjust settings")],
links: Vec::new(),
chrome: ModalChrome::new(ModalKind::Normal, true)
.with_max_content_width(PROSE_CONTENT_WIDTH),
})
}
pub fn with_theme_downgrade(mut self, configured: String, substituted: &'static str) -> Self {
self.downgrade = Some(ThemeDowngrade {
configured,
substituted,
});
self.buttons.clear();
self
}
fn record_outcome(&self, adjust: bool) -> ModalOutcome {
self.record_outcome_following(adjust, None)
}
fn record_outcome_following(
&self,
adjust: bool,
link: Option<ModalLinkTarget>,
) -> ModalOutcome {
let fp = self.fingerprint.clone();
ModalOutcome::CloseAnd(Box::new(move |app| {
if !app.config.editor.seen_terminal_fingerprints.contains(&fp) {
app.config.editor.seen_terminal_fingerprints.push(fp);
app.save_config_with_flash("failed to persist terminal capabilities notice");
}
if adjust {
app.open_welcome_modal();
}
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.record_outcome_following(false, Some(link.target.clone())),
None => ModalOutcome::Continue,
},
}
}
fn resolve(&self, response: ModalResponse) -> ModalOutcome {
match response {
ModalResponse::Continue => ModalOutcome::Continue,
ModalResponse::ButtonPressed(ADJUST_BUTTON) => self.record_outcome(true),
ModalResponse::Cancelled | ModalResponse::ButtonPressed(_) => {
self.record_outcome(false)
}
}
}
}
impl Modal for TerminalCapabilitiesModal {
fn render(&mut self, frame: &mut Frame<'_>, area: Rect, ctx: &ModalRenderCtx<'_>) {
let mut body: Vec<Line<'static>> = Vec::new();
body.push(Line::raw(
"It looks like you're using a new terminal application. edamame has \
detected the following capabilities for this terminal:",
));
body.push(Line::raw(""));
body.extend(build_cap_lines(&self.summary.rows, ctx.theme));
if !self.summary.all_ok() {
body.push(Line::raw(""));
body.push(Line::styled(
"Items marked ✗ will be disabled or degraded automatically.",
Style::default().fg(ctx.theme.palette.warning),
));
}
if let Some(d) = &self.downgrade {
body.push(Line::raw(""));
body.extend(theme_downgrade_lines(
&d.configured,
d.substituted,
ctx.theme,
));
}
if self.downgrade.is_none() {
body.push(Line::raw(""));
body.push(Line::raw(
"Adjust settings re-opens the setup screen so theme, images, and diagrams \
can be matched to this terminal.",
));
}
self.links = DOCS_FOOTNOTE.append_to(&mut body, self.chrome.focused_link(), ctx.theme);
self.chrome.render_with_links(
frame,
area,
ctx,
"Terminal capabilities",
&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::config::Theme;
use crate::document::ParsedDoc;
use crossterm::event::KeyCode;
#[test]
fn a_folded_downgrade_drops_the_welcome_route() {
let modal = TerminalCapabilitiesModal::from_capabilities(&Capabilities::minimal(), &[])
.expect("unseen fingerprint yields a modal")
.with_theme_downgrade("Dracula".into(), "256 Dark");
assert!(modal.buttons.is_empty());
}
#[test]
fn adjust_button_index_matches_its_position() {
let modal = TerminalCapabilitiesModal::from_capabilities(&Capabilities::minimal(), &[])
.expect("unseen fingerprint yields a modal");
assert_eq!(modal.buttons[ADJUST_BUTTON].label, "Adjust settings");
assert_eq!(modal.buttons.len(), 1);
}
#[test]
fn the_docs_link_names_a_page_that_exists() {
let ModalLinkTarget { id, fragment } = DOCS_FOOTNOTE.target;
assert_eq!(
fragment, None,
"this notice points at the page top; see DOCS_FOOTNOTE"
);
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let parsed = ParsedDoc::build(&id.source(), theme, true, 80);
assert!(!parsed.blocks.is_empty(), "{} embedded empty", id.title());
}
#[test]
fn tab_walks_links_before_buttons() {
let mut modal = TerminalCapabilitiesModal::from_capabilities(&Capabilities::minimal(), &[])
.expect("unseen fingerprint yields a modal");
let tab = KeyEvent::new(KeyCode::Tab, crossterm::event::KeyModifiers::NONE);
assert_eq!(
modal.chrome.focused_link(),
None,
"buttons hold focus first"
);
modal.chrome.on_key_linkable(&tab, 1, 1);
assert_eq!(modal.chrome.focused_link(), Some(0), "Tab reaches the link");
modal.chrome.on_key_linkable(&tab, 1, 1);
assert_eq!(modal.chrome.focused_link(), None, "and wraps back");
}
#[test]
fn enter_on_the_focused_link_resolves_as_a_link() {
let mut modal = TerminalCapabilitiesModal::from_capabilities(&Capabilities::minimal(), &[])
.expect("unseen fingerprint yields a modal");
let tab = KeyEvent::new(KeyCode::Tab, crossterm::event::KeyModifiers::NONE);
modal.chrome.on_key_linkable(&tab, 1, 1);
let enter = KeyEvent::new(KeyCode::Enter, crossterm::event::KeyModifiers::NONE);
let response = modal.chrome.on_key_linkable(&enter, 1, 1);
assert_eq!(response, LinkableResponse::Link(0));
}
#[test]
fn enter_on_a_link_does_not_press_the_button() {
let mut modal = TerminalCapabilitiesModal::from_capabilities(&Capabilities::minimal(), &[])
.expect("unseen fingerprint yields a modal");
let tab = KeyEvent::new(KeyCode::Tab, crossterm::event::KeyModifiers::NONE);
modal.chrome.on_key_linkable(&tab, 1, 1);
let enter = KeyEvent::new(KeyCode::Enter, crossterm::event::KeyModifiers::NONE);
let response = modal.chrome.on_key_linkable(&enter, 1, 1);
assert!(
!matches!(
response,
LinkableResponse::Modal(ModalResponse::ButtonPressed(_))
),
"a focused link swallows Enter"
);
}
#[test]
fn seen_fingerprint_suppresses_the_notice() {
let caps = Capabilities::minimal();
let seen = vec![caps.fingerprint()];
assert!(TerminalCapabilitiesModal::from_capabilities(&caps, &seen).is_none());
}
}
#[cfg(test)]
mod click_tests {
use super::*;
use crate::app::test_utils::make_app;
use crate::config::Config;
use crate::config::Theme;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
#[test]
fn clicking_the_docs_link_opens_that_manual_page() {
let _iso = crate::test_env::config_isolation();
let mut app = make_app();
let mut modal = TerminalCapabilitiesModal::from_capabilities(&Capabilities::minimal(), &[])
.expect("unseen fingerprint yields a modal");
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 its link");
let outcome = modal.handle_click(rect.x, rect.y, &mut app);
match outcome {
ModalOutcome::CloseAnd(f) => f(&mut app),
_ => panic!("a link click closes the notice and follows the link"),
}
assert_eq!(
app.open_doc,
Some(DOCS_FOOTNOTE.target.id),
"the click opened the manual page the link named"
);
assert!(app
.config
.editor
.seen_terminal_fingerprints
.contains(&Capabilities::minimal().fingerprint()));
}
#[test]
fn a_click_outside_the_link_does_not_navigate() {
let _iso = crate::test_env::config_isolation();
let mut app = make_app();
let mut modal = TerminalCapabilitiesModal::from_capabilities(&Capabilities::minimal(), &[])
.expect("unseen fingerprint yields a modal");
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 outcome = modal.handle_click(0, 0, &mut app);
if let ModalOutcome::CloseAnd(f) = outcome {
f(&mut app);
}
assert_eq!(app.open_doc, None);
}
}