use std::path::PathBuf;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Margin, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap};
use toml_edit::DocumentMut;
use crate::tui::book_toml::{
ConfigItem, DiffLine, ItemKind, apply_item, build_items, diff, find_book_toml,
};
use crate::tui::theme;
use crate::tui::widget::{self, Input};
const LIST_VALUE_PAD: usize = 52;
const DIFF_MODAL_WIDTH_PERCENT: u16 = 70;
const DIFF_MODAL_HEIGHT_PERCENT: u16 = 60;
pub struct ConfigView {
pub book_toml: Option<PathBuf>,
pub not_found_error: bool,
pub doc: Option<DocumentMut>,
pub original: String,
pub items: Vec<ConfigItem>,
pub selected: usize,
pub editing: bool,
pub input: Input,
pub confirm_apply: bool,
pub status: Option<ConfigStatus>,
}
pub enum ConfigStatus {
Written,
NoChanges,
Error(String),
}
impl Default for ConfigView {
fn default() -> Self {
Self::new()
}
}
impl ConfigView {
pub fn new() -> Self {
let mut view = Self {
book_toml: find_book_toml(&std::env::current_dir().unwrap_or_default()),
not_found_error: false,
doc: None,
original: String::new(),
items: Vec::new(),
selected: 0,
editing: false,
input: Input::new(),
confirm_apply: false,
status: None,
};
view.reload();
view
}
fn reload(&mut self) {
let Some(path) = self.book_toml.clone() else {
self.not_found_error = true;
return;
};
match std::fs::read_to_string(&path) {
Ok(text) => {
let doc: DocumentMut = match text.parse() {
Ok(d) => d,
Err(e) => {
self.not_found_error = false;
self.status = Some(ConfigStatus::Error(format!(
"cannot parse {}: {e}",
path.display()
)));
return;
}
};
self.original = text;
self.items = build_items(&doc);
self.doc = Some(doc);
self.not_found_error = false;
}
Err(e) => {
self.not_found_error = false;
self.status = Some(ConfigStatus::Error(format!(
"cannot read {}: {e}",
path.display()
)));
}
}
}
pub fn current_item(&self) -> Option<&ConfigItem> {
self.items.get(self.selected)
}
pub fn cycle_selected(&mut self, delta: isize) {
if let Some(item) = self.items.get_mut(self.selected)
&& let ItemKind::Enum(choices, idx) = &mut item.kind
{
let n = choices.len() as isize;
*idx = ((*idx as isize + delta).rem_euclid(n)) as usize;
}
}
pub fn toggle_selected(&mut self) {
if let Some(item) = self.items.get_mut(self.selected)
&& let ItemKind::Bool(b) = &mut item.kind
{
*b = !*b;
}
}
pub fn preview(&self) -> String {
let mut doc = self.doc.clone().unwrap_or_default();
for item in &self.items {
apply_item(&mut doc, item);
}
doc.to_string()
}
pub fn pending_diff(&self) -> Vec<DiffLine> {
diff(&self.original, &self.preview())
}
pub fn write(&mut self) -> std::io::Result<()> {
let Some(path) = self.book_toml.clone() else {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"book.toml not found",
));
};
let content = self.preview();
crate::tui::book_toml::atomic_write(&path, &content)?;
self.original = content;
self.status = Some(ConfigStatus::Written);
Ok(())
}
pub fn render(&mut self, frame: &mut Frame, area: Rect) {
if self.not_found_error {
let msg = format!(
"No book.toml found in {} or any parent directory.\n\
Run this inside a mdbook project directory.",
std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_default()
);
widget::confirm_dialog(frame, area, " book.toml not found ", &msg, "Esc to go back");
return;
}
let [header, list, detail, status_line] = Layout::vertical([
Constraint::Length(1),
Constraint::Percentage(55),
Constraint::Min(3),
Constraint::Length(1),
])
.areas(area);
let title = " book.toml editor ";
let right = self
.book_toml
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
widget::view_header(frame, header, title, &right);
let list_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(theme::border());
let list_inner = list_block.inner(list);
frame.render_widget(list_block, list);
let visible = list_inner.height as usize;
let scroll = self.selected.saturating_sub(visible.saturating_sub(1));
for (i, item) in self.items.iter().enumerate().skip(scroll).take(visible) {
let y = list_inner.y + (i - scroll) as u16;
if y >= list_inner.bottom() {
break;
}
let (key_style, val_style) = if i == self.selected {
(theme::selected(), theme::selected())
} else {
(theme::fg(), theme::dim())
};
let path_w = unicode_width::UnicodeWidthStr::width(item.path.as_str());
let pad = LIST_VALUE_PAD.saturating_sub(path_w);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(item.path.clone(), key_style),
Span::styled(" ".repeat(pad), val_style),
Span::styled(item.kind.display_value(), val_style),
])),
Rect {
x: list_inner.x,
y,
width: list_inner.width,
height: 1,
},
);
}
if let Some(item) = self.current_item() {
let lines = vec![
Line::from(Span::styled(item.description.clone(), theme::fg())),
Line::from(Span::styled("", theme::dim())),
Line::from(vec![
Span::styled("Valid: ", theme::accent_bold()),
Span::styled(item.valid.clone(), theme::dim()),
]),
Line::from(vec![
Span::styled("Default: ", theme::accent_bold()),
Span::styled(item.default.clone(), theme::dim()),
]),
Line::from(vec![
Span::styled("Current: ", theme::accent_bold()),
Span::styled(item.kind.display_value(), theme::fg()),
]),
];
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: true })
.style(theme::fg()),
Rect {
x: detail.x + 1,
y: detail.y,
width: detail.width.saturating_sub(2),
height: detail.height,
},
);
}
let status = match &self.status {
Some(ConfigStatus::Written) => {
Line::from(Span::styled("✓ Written to book.toml.", theme::success()))
}
Some(ConfigStatus::NoChanges) => {
Line::from(Span::styled("No changes to write.", theme::dim()))
}
Some(ConfigStatus::Error(msg)) => Line::from(Span::styled(msg.clone(), theme::error())),
None if self.editing => Line::from(Span::styled(
"Editing — Enter to confirm, Esc to cancel",
theme::dim(),
)),
None => Line::from(""),
};
frame.render_widget(Paragraph::new(status), status_line);
}
pub fn render_diff_modal(&self, frame: &mut Frame, area: Rect, diff: &[DiffLine]) {
let popup =
widget::centered_area(area, DIFF_MODAL_WIDTH_PERCENT, DIFF_MODAL_HEIGHT_PERCENT);
frame.render_widget(Clear, popup);
frame.render_widget(
Block::default()
.title(" Diff preview ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(theme::border_active()),
popup,
);
let inner = popup.inner(Margin {
horizontal: 1,
vertical: 1,
});
let lines: Vec<Line> = diff
.iter()
.map(|l| match l {
DiffLine::Same(t) => Line::from(Span::styled(format!(" {t}"), theme::fg())),
DiffLine::Added(t) => Line::from(Span::styled(format!("+ {t}"), theme::success())),
DiffLine::Removed(t) => Line::from(Span::styled(format!("- {t}"), theme::error())),
})
.collect();
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"Write to disk? y / n",
theme::accent_bold(),
))),
Rect {
x: inner.x,
y: popup.bottom().saturating_sub(2),
width: inner.width,
height: 1,
},
);
}
}