use std::any::Any;
use crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::Frame;
use super::chrome::ModalChrome;
use super::docs_link::{follow_and_close, DocsFootnote};
use super::types::{Modal, ModalKind, ModalOutcome, ModalRenderCtx};
use crate::app::App;
use crate::config::{ConfigWarning, WarningKind};
use crate::docs::DocId;
use crate::ui::modal::LinkableResponse;
use crate::ui::{ModalButton, ModalLink, ModalLinkTarget, ModalResponse};
pub struct ConfigWarningModal {
pub(crate) body: Vec<Line<'static>>,
pub(crate) buttons: Vec<ModalButton>,
links: Vec<ModalLink>,
chrome: ModalChrome,
}
impl ConfigWarningModal {
pub fn from_warnings(warnings: &[ConfigWarning]) -> Option<Self> {
if warnings.is_empty() {
return None;
}
let mut body: Vec<Line<'static>> = Vec::new();
body.push(Line::raw(
"Some configuration files had problems. Defaults were used for the affected entries.",
));
body.push(Line::raw(""));
for (idx, warning) in warnings.iter().enumerate() {
if idx > 0 {
body.push(Line::raw(""));
}
body.push(Line::raw(format!("• {}", warning.path.display())));
match &warning.kind {
WarningKind::ParseError(msg) => {
body.push(Line::raw(" Parse error:"));
for line in msg.lines() {
body.push(Line::raw(format!(" {line}")));
}
}
WarningKind::UnknownKeys(keys) => {
body.push(Line::raw(" Unrecognized keys (ignored):"));
for k in keys {
body.push(Line::raw(format!(" {k}")));
}
}
WarningKind::InvalidKeybindings(errs) => {
body.push(Line::raw(" Invalid keybinding entries (skipped):"));
for e in errs {
body.push(Line::raw(format!(" {e}")));
}
}
WarningKind::InvalidValue { key, message } => {
body.push(Line::raw(format!(" Invalid value for {key}:")));
body.push(Line::raw(format!(" {message}")));
}
WarningKind::MissingTheme {
requested,
fallback,
} => {
body.push(Line::raw(format!(" Theme '{requested}' was not found.")));
body.push(Line::raw(format!(
" Falling back to '{fallback}'; config.toml has been updated."
)));
}
}
}
Some(Self {
body,
buttons: Vec::new(),
links: Vec::new(),
chrome: ModalChrome::new(ModalKind::Warning, true),
})
}
fn resolve(&self, response: LinkableResponse) -> ModalOutcome {
match response {
LinkableResponse::Modal(ModalResponse::Continue) => ModalOutcome::Continue,
LinkableResponse::Modal(ModalResponse::Cancelled | ModalResponse::ButtonPressed(_)) => {
ModalOutcome::Close
}
LinkableResponse::Link(idx) => match self.links.get(idx) {
Some(link) => follow_and_close(link.target.clone()),
None => ModalOutcome::Continue,
},
}
}
}
const DOCS_FOOTNOTE: DocsFootnote = DocsFootnote {
label: "When something is wrong with your config",
target: ModalLinkTarget {
id: DocId::Configuration,
fragment: Some("when-something-is-wrong-with-your-config"),
},
trailer: " explains how these are handled.",
};
impl Modal for ConfigWarningModal {
fn render(&mut self, frame: &mut Frame<'_>, area: Rect, ctx: &ModalRenderCtx<'_>) {
let mut body = self.body.clone();
self.links = DOCS_FOOTNOTE.append_to(&mut body, self.chrome.focused_link(), ctx.theme);
self.chrome.render_with_links(
frame,
area,
ctx,
"Config warnings",
&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(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(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 std::path::PathBuf;
#[test]
fn the_docs_link_names_a_heading_that_exists() {
let ModalLinkTarget { id, fragment } = DOCS_FOOTNOTE.target;
let fragment = fragment.expect("the link names a section, not just a page");
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let parsed = ParsedDoc::build(&id.source(), theme, true, 80);
assert!(
parsed.heading_anchors.contains_key(fragment),
"'{fragment}' is not a heading in {}",
id.title()
);
}
#[test]
fn empty_warnings_returns_none() {
assert!(ConfigWarningModal::from_warnings(&[]).is_none());
}
#[test]
fn parse_error_body_contains_path_and_message() {
let warnings = vec![ConfigWarning {
path: PathBuf::from("/home/u/.config/edamame/config.toml"),
kind: WarningKind::ParseError("expected integer, found string at line 3".into()),
}];
let modal = ConfigWarningModal::from_warnings(&warnings).expect("modal built");
let joined = modal
.body
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("/home/u/.config/edamame/config.toml"));
assert!(joined.contains("Parse error"));
assert!(joined.contains("line 3"));
assert!(modal.buttons.is_empty());
}
#[test]
fn unknown_keys_body_lists_each_path() {
let warnings = vec![ConfigWarning {
path: PathBuf::from("config.toml"),
kind: WarningKind::UnknownKeys(vec!["editor.tab_widht".into(), "boguss".into()]),
}];
let modal = ConfigWarningModal::from_warnings(&warnings).expect("modal built");
let joined = modal
.body
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("Unrecognized keys"));
assert!(joined.contains("editor.tab_widht"));
assert!(joined.contains("boguss"));
}
#[test]
fn invalid_keybindings_body_lists_each_error() {
let warnings = vec![ConfigWarning {
path: PathBuf::from("keybindings.toml"),
kind: WarningKind::InvalidKeybindings(vec![
"Quitt = \"ctrl+x\": unknown action name: 'Quitt'".into(),
]),
}];
let modal = ConfigWarningModal::from_warnings(&warnings).expect("modal built");
let joined = modal
.body
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("Invalid keybinding entries"));
assert!(joined.contains("Quitt"));
}
#[test]
fn invalid_value_body_lists_key_and_message() {
let warnings = vec![ConfigWarning {
path: PathBuf::from("config.toml"),
kind: WarningKind::InvalidValue {
key: "editor.autosave_idle_ms".into(),
message: "value 0 is outside the supported range (1000 < N < 600000); \
using the default (5000) instead"
.into(),
},
}];
let modal = ConfigWarningModal::from_warnings(&warnings).expect("modal built");
let joined = modal
.body
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("Invalid value for editor.autosave_idle_ms"));
assert!(joined.contains("outside the supported range"));
assert!(joined.contains("using the default (5000)"));
}
#[test]
fn missing_theme_body_mentions_requested_and_fallback() {
let warnings = vec![ConfigWarning {
path: PathBuf::from("/home/u/.config/edamame/themes/solarized.toml"),
kind: WarningKind::MissingTheme {
requested: "solarized".into(),
fallback: "Edamame".into(),
},
}];
let modal = ConfigWarningModal::from_warnings(&warnings).expect("modal built");
let joined = modal
.body
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("solarized"));
assert!(joined.contains("Edamame"));
assert!(joined.contains("config.toml has been updated"));
}
#[test]
fn multiple_warnings_separated_by_blank_lines() {
let warnings = vec![
ConfigWarning {
path: PathBuf::from("a.toml"),
kind: WarningKind::ParseError("oops".into()),
},
ConfigWarning {
path: PathBuf::from("b.toml"),
kind: WarningKind::UnknownKeys(vec!["x".into()]),
},
];
let modal = ConfigWarningModal::from_warnings(&warnings).expect("modal built");
let joined = modal
.body
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("a.toml"));
assert!(joined.contains("b.toml"));
}
use crate::app::test_utils::make_app;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test]
fn modal_dismissed_on_escape() {
let mut app = make_app();
let warnings = vec![ConfigWarning {
path: PathBuf::from("config.toml"),
kind: WarningKind::ParseError("oops".into()),
}];
let modal = ConfigWarningModal::from_warnings(&warnings).expect("modal built");
app.modal_stack.push(Box::new(modal));
assert!(app.modal_stack.contains::<ConfigWarningModal>());
app.dispatch_modal_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), 40, 80);
assert!(!app.modal_stack.contains::<ConfigWarningModal>());
}
}