use super::{FieldType, SettingsEditor, SettingsField, TABS, value_text};
use crate::tui::{
activity::truncate_display_width,
modal_container::{draw_modal_container, modal_area, modal_tab},
theme::MissionControlTheme,
};
use ratatui::{
Frame,
layout::{Constraint, Layout, Margin, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, List, ListItem, ListState, Paragraph, Wrap},
};
const BROWSE_SHORTCUTS: &str = "[Ctrl-S] Save • [Enter] Edit • [Tab] tabs • [Ctrl-G] scope";
const EDIT_SHORTCUTS: &str = "[Enter] Accept";
fn wrapped_height(text: &str, width: u16) -> u16 {
Paragraph::new(text)
.wrap(Wrap { trim: true })
.line_count(width.max(1))
.min(usize::from(u16::MAX)) as u16
}
fn fit_rows(mut heights: [u16; 8], available: u16, editing: bool) -> [u16; 8] {
let mut excess = heights.iter().sum::<u16>().saturating_sub(available);
for (index, minimum) in [
(1, 0),
(4, 0),
(6, 0),
(2, 1),
(3, if editing { 3 } else { 0 }),
(7, 0),
(5, 3),
(3, 0),
(5, 1),
(0, 0),
(5, 0),
(2, 0),
] {
let removed = heights[index].saturating_sub(minimum).min(excess);
heights[index] -= removed;
excess -= removed;
}
heights
}
fn modal_layout(area: Rect, editor: &SettingsEditor) -> (Rect, [u16; 8]) {
let default_area = modal_area(area, 60, 60);
let content_width = default_area.width.saturating_sub(4);
let notice_height = 3;
let description_height = editor
.fields
.iter()
.map(|field| wrapped_height(field.help, content_width.saturating_sub(2)))
.max()
.unwrap_or(1)
.saturating_add(2);
let longest_tab = (0..TABS.len())
.map(|tab| {
editor
.fields
.iter()
.filter(|field| field.tab == tab && field.kind != FieldType::Hidden)
.map(|field| 1 + usize::from(field.section_heading.is_some()))
.sum::<usize>()
})
.max()
.unwrap_or(0)
.max(1);
let heights = [
1,
1,
longest_tab as u16,
3,
2,
description_height,
1,
notice_height,
];
let needs_space = default_area.width < 76
|| heights.iter().sum::<u16>().saturating_add(2) > default_area.height;
let area = if needs_space {
modal_area(area, 80, 80)
} else {
default_area
};
(area, heights)
}
fn content_rows(area: Rect, editor: &SettingsEditor) -> std::rc::Rc<[Rect]> {
let (area, heights) = modal_layout(area, editor);
let inner = area.inner(Margin {
horizontal: 2,
vertical: 1,
});
Layout::vertical(
fit_rows(heights, inner.height, editor.editing.is_some()).map(Constraint::Length),
)
.split(inner)
}
pub(super) fn models_geometry(area: Rect, editor: &SettingsEditor) -> (Rect, Rect) {
let rows = content_rows(area, editor);
let button = Rect::new(rows[2].x, rows[2].y, rows[2].width, rows[2].height.min(1));
let list = Rect::new(
button.x,
button.bottom(),
button.width,
rows[5].bottom().saturating_sub(button.bottom()),
);
(button, list)
}
pub(crate) fn draw(
frame: &mut Frame<'_>,
terminal_area: Rect,
editor: &SettingsEditor,
theme: MissionControlTheme,
) {
let fields = editor.visible_fields();
let notice = crate::output::sanitize_display_text(&editor.notice);
let (area, _) = modal_layout(terminal_area, editor);
let dirty = editor
.fields
.iter()
.any(|field| field.value != field.original);
let status = if editor.busy {
" · working"
} else if editor.editing.is_some() {
" · editing"
} else if dirty {
" · * unsaved"
} else {
""
};
let close_hint = if editor.busy {
None
} else if editor.editing.is_some() || editor.model_picker.is_some() {
Some("[Esc] cancel")
} else if editor.discard_armed {
Some("[Esc] discard")
} else if dirty {
Some("[Esc] confirm discard")
} else {
Some("[Esc] close")
};
let shortcuts = if editor.busy {
""
} else if editor.editing.is_some() || editor.model_picker.is_some() {
EDIT_SHORTCUTS
} else if area.width < 56 {
"[Ctrl-S] Save [Enter] [Tab] [Ctrl-G]"
} else {
BROWSE_SHORTCUTS
};
let inner = draw_modal_container(
frame,
area,
format!("Settings • {}{status}", editor.scope.label()),
shortcuts,
theme,
close_hint,
)
.inner(Margin {
horizontal: 1,
vertical: 0,
});
let rows = content_rows(terminal_area, editor);
frame.render_widget(
Paragraph::new(tab_line(editor.tab, inner.width, theme)),
rows[0],
);
if editor.model_picker.is_some() {
let list = Rect::new(
rows[2].x,
rows[2].y,
rows[2].width,
rows[5].bottom().saturating_sub(rows[2].y),
);
draw_model_picker(frame, list, editor, theme);
} else if editor.tab == super::MODELS_TAB {
let (button, list) = models_geometry(terminal_area, editor);
draw_models(frame, button, list, editor, theme);
} else {
draw_fields(frame, rows[2], editor, &fields, theme);
if let Some(edit) = &editor.editing {
edit.render(frame, rows[3], "Value", "blank = default", true, theme);
}
if let Some(field) = fields.get(editor.selected) {
let description = Paragraph::new(field.help)
.style(Style::default().fg(theme.text_primary()))
.wrap(Wrap { trim: true });
let description = if rows[5].height >= 3 {
description.block(
Block::bordered()
.border_style(Style::default().fg(theme.border_alternate()))
.title(Line::styled(
format!("{} Description", field.label),
Style::default()
.fg(theme.text_title())
.add_modifier(Modifier::BOLD),
)),
)
} else {
description
};
frame.render_widget(description, rows[5]);
}
}
frame.render_widget(
Paragraph::new(notice)
.centered()
.style(
Style::default()
.fg(theme.text_status())
.add_modifier(Modifier::BOLD),
)
.wrap(Wrap { trim: true }),
rows[7],
);
}
fn draw_model_picker(
frame: &mut Frame<'_>,
area: Rect,
editor: &SettingsEditor,
theme: MissionControlTheme,
) {
let Some(picker) = &editor.model_picker else {
return;
};
if picker.choices.is_empty() {
frame.render_widget(
Paragraph::new("No enabled models in the cached catalog. Esc returns to settings."),
area,
);
return;
}
let items: Vec<_> = picker
.choices
.iter()
.map(|choice| {
ListItem::new(crate::output::sanitize_display_text(
choice
.as_ref()
.map_or(picker.default_label, |choice| &choice.label),
))
})
.collect();
let mut state = ListState::default().with_selected(Some(picker.selected));
frame.render_stateful_widget(
List::new(items).highlight_symbol("› ").highlight_style(
Style::default()
.fg(theme.text_accent())
.bg(theme.surface_selection()),
),
area,
&mut state,
);
}
fn tab_line(selected: usize, width: u16, theme: MissionControlTheme) -> Line<'static> {
let budget = usize::from(width).saturating_sub(4);
let mut start = selected;
let mut end = selected + 1;
let mut used = TABS[selected].len() + 3;
loop {
if end < TABS.len() && used + TABS[end].len() + 3 <= budget {
used += TABS[end].len() + 3;
end += 1;
} else if start > 0 && used + TABS[start - 1].len() + 3 <= budget {
start -= 1;
used += TABS[start].len() + 3;
} else {
break;
}
}
let mut spans = vec![Span::raw(if start > 0 { "‹ " } else { "" })];
for (index, name) in TABS.iter().enumerate().take(end).skip(start) {
spans.push(modal_tab(name, index == selected, theme));
spans.push(Span::raw(" "));
}
if end < TABS.len() {
spans.push(Span::raw("›"));
}
Line::from(spans)
}
fn draw_fields(
frame: &mut Frame<'_>,
area: Rect,
editor: &SettingsEditor,
fields: &[&SettingsField],
theme: MissionControlTheme,
) {
let label_width = fields
.iter()
.map(|field| field.label.len())
.max()
.unwrap_or(0)
.min(usize::from(area.width.saturating_sub(12)));
let value_width = usize::from(area.width).saturating_sub(label_width + 6);
let mut items = Vec::new();
let mut selected_row = None;
for (index, field) in fields.iter().enumerate() {
if let Some(heading) = field.section_heading {
items.push(ListItem::new(Line::styled(
heading,
Style::default()
.fg(theme.text_title())
.add_modifier(Modifier::BOLD),
)));
}
if index == editor.selected {
selected_row = Some(items.len());
}
let value = match field.kind {
FieldType::Model => editor.model_selection_label(field),
FieldType::Boolean => if field.value.as_bool() == Some(true) {
"On"
} else {
"Off"
}
.into(),
_ if field.value.is_null() => "default".into(),
_ => value_text(&field.value),
};
let label = truncate_display_width(field.label, label_width);
items.push(ListItem::new(Line::from(vec![
Span::styled(
if field.value != field.original {
"* "
} else {
" "
},
Style::default().fg(theme.text_accent()),
),
Span::raw(format!("{label:label_width$} ")),
Span::styled(
truncate_display_width(&crate::output::sanitize_display_text(&value), value_width),
Style::default().fg(theme.text_accent()),
),
])));
}
let mut selection = ListState::default().with_selected(selected_row);
frame.render_stateful_widget(
List::new(items).highlight_symbol("› ").highlight_style(
Style::default()
.bg(theme.surface_selection())
.add_modifier(Modifier::BOLD),
),
area,
&mut selection,
);
}
fn draw_models(
frame: &mut Frame<'_>,
button: Rect,
area: Rect,
editor: &SettingsEditor,
theme: MissionControlTheme,
) {
let selected_style = Style::default()
.fg(theme.text_accent())
.bg(theme.surface_selection());
let label = if editor.catalog_loading {
"Refreshing catalog…"
} else {
"[ Refresh catalog ]"
};
frame.render_widget(
Paragraph::new(label).style(if editor.selected == 0 {
selected_style
} else {
theme.muted(false)
}),
button,
);
let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(2)]).split(area);
if editor.models.is_empty() {
frame.render_widget(
Paragraph::new(if editor.catalog_loading {
"Loading models…"
} else {
"No models available. Try Refresh catalog."
}),
rows[0],
);
} else {
let offset = super::models_scroll_offset(editor.selected, rows[0].height);
let items = editor
.models
.iter()
.enumerate()
.skip(offset)
.take(usize::from(rows[0].height))
.map(|(i, (id, label))| {
let label = crate::output::sanitize_display_text(label);
ListItem::new(format!(
"[{}] {label}",
if editor.model_enabled(id) { "x" } else { " " }
))
.style(if editor.selected == i + 1 {
selected_style
} else {
Style::default().fg(theme.text_primary())
})
});
frame.render_widget(List::new(items.collect::<Vec<_>>()), rows[0]);
}
frame.render_widget(
Paragraph::new(crate::output::sanitize_display_text(&editor.catalog_notice))
.wrap(Wrap { trim: true }),
rows[1],
);
}