use super::{FieldType, SettingsEditor, SettingsField, TABS, value_text};
use crate::tui::{
activity::truncate_display_width,
modal_container::{draw_modal_container, modal_area, modal_tab},
render::render_scrollbar_with_theme,
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 MODELS_SHORTCUTS: &str =
"[Ctrl-S] Save • [Enter] Toggle • [←/→] Fold • [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)
}
fn close_hint(editor: &SettingsEditor) -> Option<&'static str> {
if editor.busy {
None
} else if editor.editing.is_some()
|| editor.model_picker.is_some()
|| editor.choice_picker.is_some()
|| editor.multi_choice_picker.is_some()
{
Some("[Esc] cancel")
} else if editor.discard_armed {
Some("[Esc] discard")
} else if editor
.fields
.iter()
.any(|field| field.value != field.original)
{
Some("[Esc] confirm discard")
} else {
Some("[Esc] close")
}
}
pub(crate) fn close_control_area(area: Rect, editor: &SettingsEditor) -> Option<Rect> {
crate::tui::modal_container::close_control_area(
modal_layout(area, editor).0,
close_hint(editor),
)
}
fn render_scrolling_list(
frame: &mut Frame<'_>,
area: Rect,
list: List<'_>,
selected: usize,
content_length: usize,
theme: MissionControlTheme,
) {
if area.is_empty() {
return;
}
let content_area = Rect {
width: area.width.saturating_sub(1),
..area
};
let mut state = ListState::default().with_selected(Some(selected));
frame.render_stateful_widget(list, content_area, &mut state);
render_scrollbar_with_theme(
frame,
area,
content_length,
state.offset(),
usize::from(area.height),
theme,
);
}
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 = close_hint(editor);
let shortcuts = if editor.busy {
""
} else if editor.editing.is_some()
|| editor.model_picker.is_some()
|| editor.choice_picker.is_some()
|| editor.multi_choice_picker.is_some()
{
EDIT_SHORTCUTS
} else if area.width < 56 {
"[Ctrl-S] Save [Enter] [Tab] [Ctrl-G]"
} else if editor.tab == super::MODELS_TAB {
MODELS_SHORTCUTS
} 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 let Some(picker) = &editor.multi_choice_picker {
let list = Rect::new(
rows[2].x,
rows[2].y,
rows[2].width,
rows[5].bottom().saturating_sub(rows[2].y),
);
let selected_values = editor.fields[picker.field_index].value.as_array();
let items = picker
.choices
.iter()
.map(|choice| {
let checked = selected_values.is_some_and(|values| {
values.iter().any(|value| value.as_str() == Some(choice))
});
ListItem::new(format!("[{}] {choice}", if checked { "x" } else { " " }))
})
.collect::<Vec<_>>();
render_scrolling_list(
frame,
list,
List::new(items).highlight_symbol("› ").highlight_style(
Style::default()
.fg(theme.text_accent())
.bg(theme.surface_selection()),
),
picker.selected,
picker.choices.len(),
theme,
);
} else if let Some(picker) = &editor.choice_picker {
let list = Rect::new(
rows[2].x,
rows[2].y,
rows[2].width,
rows[5].bottom().saturating_sub(rows[2].y),
);
let items = picker
.choices
.iter()
.map(|choice| ListItem::new(*choice))
.collect::<Vec<_>>();
render_scrolling_list(
frame,
list,
List::new(items).highlight_symbol("› ").highlight_style(
Style::default()
.fg(theme.text_accent())
.bg(theme.surface_selection()),
),
picker.selected,
picker.choices.len(),
theme,
);
} else 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();
render_scrolling_list(
frame,
area,
List::new(items).highlight_symbol("› ").highlight_style(
Style::default()
.fg(theme.text_accent())
.bg(theme.surface_selection()),
),
picker.selected,
picker.choices.len(),
theme,
);
}
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;
let jev_enabled = editor
.fields
.iter()
.find(|field| field.path == "internal_tooling.jev.enabled")
.and_then(|field| field.value.as_bool())
.unwrap_or(false);
for (index, field) in fields.iter().enumerate() {
let inactive_jev_feature = field.path.starts_with("internal_tooling.jev.")
&& field.path != "internal_tooling.jev.enabled"
&& field.path != "internal_tooling.jev.reset_cache"
&& !jev_enabled;
if let Some(heading) = field.section_heading {
let heading = if inactive_jev_feature {
format!("{heading} (inactive — Jev is off)")
} else {
heading.to_string()
};
items.push(ListItem::new(Line::styled(
heading,
if inactive_jev_feature {
theme.muted(false)
} else {
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::styled(
format!("{label:label_width$} "),
if inactive_jev_feature {
theme.muted(false)
} else {
Style::default()
},
),
Span::styled(
truncate_display_width(&crate::output::sanitize_display_text(&value), value_width),
if inactive_jev_feature {
theme.muted(false)
} else {
Style::default().fg(theme.text_accent())
},
),
])));
}
let content_length = items.len();
render_scrolling_list(
frame,
area,
List::new(items).highlight_symbol("› ").highlight_style(
Style::default()
.bg(theme.surface_selection())
.add_modifier(Modifier::BOLD),
),
selected_row.unwrap_or(0),
content_length,
theme,
);
}
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 controls = editor.model_controls();
let selected_control = editor.selected.saturating_sub(1);
let selected_row = editor.model_control_document_row(selected_control);
let offset = super::models_scroll_offset(selected_row, rows[0].height);
let mut items = Vec::new();
for row in editor.model_rows() {
let Some(control_index) = row else {
items.push(ListItem::new("└─".to_string()).style(theme.border(false)));
continue;
};
let row_style = |default: Style| {
if editor.selected == control_index + 1 {
selected_style
} else {
default
}
};
match controls[control_index] {
super::ModelControl::Provider { start, end } => {
let provider = super::model_provider(&editor.models[start].0);
let enabled =
(start..end).all(|index| editor.model_enabled(&editor.models[index].0));
let arrow = if editor.provider_collapsed(provider) {
"▸"
} else {
"▾"
};
let text = format!(
"┌─ {arrow} [{}] {provider} ({})",
if enabled { "x" } else { " " },
end - start
);
items.push(ListItem::new(text).style(row_style(theme.accent(true))));
}
super::ModelControl::Model(index) => {
let (id, label) = &editor.models[index];
let label = crate::output::sanitize_display_text(label);
let text = format!(
"│ [{}] {label}",
if editor.model_enabled(id) { "x" } else { " " }
);
items.push(
ListItem::new(text)
.style(row_style(Style::default().fg(theme.text_primary()))),
);
}
}
}
let content_length = items.len();
let content_area = Rect {
width: rows[0].width.saturating_sub(1),
..rows[0]
};
frame.render_widget(
List::new(
items
.into_iter()
.skip(offset)
.take(usize::from(rows[0].height))
.collect::<Vec<_>>(),
),
content_area,
);
render_scrollbar_with_theme(
frame,
rows[0],
content_length,
offset,
usize::from(rows[0].height),
theme,
);
}
frame.render_widget(
Paragraph::new(crate::output::sanitize_display_text(&editor.catalog_notice))
.wrap(Wrap { trim: true }),
rows[1],
);
}