use rust_i18n::t;
use crate::primitives::display_width::str_width;
use super::items::SettingControl;
use super::layout::{SettingsHit, SettingsLayout};
use super::search::SearchResult;
use super::state::SettingsState;
use crate::view::controls::{
render_dropdown_aligned, render_number_input_aligned, render_text_input_aligned,
render_toggle_aligned, DropdownColors, MapColors, NumberInputColors, TextInputColors,
TextListColors, ToggleColors,
};
use crate::view::theme::Theme;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
#[allow(clippy::too_many_arguments)]
fn build_selection_spans(
display_text: &str,
display_len: usize,
line_idx: usize,
start_row: usize,
start_col: usize,
end_row: usize,
end_col: usize,
text_color: Color,
selection_bg: Color,
) -> Vec<Span<'static>> {
let chars: Vec<char> = display_text.chars().collect();
let char_count = chars.len();
let (sel_start, sel_end) = if line_idx < start_row || line_idx > end_row {
(char_count, char_count)
} else if line_idx == start_row && line_idx == end_row {
let start = byte_to_char_idx(display_text, start_col).min(char_count);
let end = byte_to_char_idx(display_text, end_col).min(char_count);
(start, end)
} else if line_idx == start_row {
let start = byte_to_char_idx(display_text, start_col).min(char_count);
(start, char_count)
} else if line_idx == end_row {
let end = byte_to_char_idx(display_text, end_col).min(char_count);
(0, end)
} else {
(0, char_count)
};
let mut spans = Vec::new();
let normal_style = Style::default().fg(text_color);
let selected_style = Style::default().fg(text_color).bg(selection_bg);
if sel_start >= sel_end || sel_start >= char_count {
let padded = format!("{:width$}", display_text, width = display_len);
spans.push(Span::styled(padded, normal_style));
} else {
if sel_start > 0 {
let before: String = chars[..sel_start].iter().collect();
spans.push(Span::styled(before, normal_style));
}
let selected: String = chars[sel_start..sel_end].iter().collect();
spans.push(Span::styled(selected, selected_style));
if sel_end < char_count {
let after: String = chars[sel_end..].iter().collect();
spans.push(Span::styled(after, normal_style));
}
let current_len = char_count;
if current_len < display_len {
let padding = " ".repeat(display_len - current_len);
spans.push(Span::styled(padding, normal_style));
}
}
spans
}
fn byte_to_char_idx(s: &str, byte_offset: usize) -> usize {
s.char_indices()
.take_while(|(i, _)| *i < byte_offset)
.count()
}
pub fn render_settings(
frame: &mut Frame,
area: Rect,
state: &mut SettingsState,
theme: &Theme,
) -> SettingsLayout {
let modal_width = (area.width * 80 / 100).min(100);
let modal_height = area.height * 90 / 100;
let modal_x = (area.width.saturating_sub(modal_width)) / 2;
let modal_y = (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
frame.render_widget(Clear, modal_area);
let title = if state.has_changes() {
format!(" Settings [{}] • (modified) ", state.target_layer_name())
} else {
format!(" Settings [{}] ", state.target_layer_name())
};
let block = Block::default()
.title(title.as_str())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.popup_border_fg))
.style(Style::default().bg(theme.popup_bg));
frame.render_widget(block, modal_area);
let inner_area = Rect::new(
modal_area.x + 1,
modal_area.y + 1,
modal_area.width.saturating_sub(2),
modal_area.height.saturating_sub(2),
);
let narrow_mode = inner_area.width < 60;
let search_area = Rect::new(inner_area.x, inner_area.y, inner_area.width, 1);
let search_header_height = 1;
if state.search_active {
render_search_header(frame, search_area, state, theme);
} else {
render_search_hint(frame, search_area, theme);
}
let footer_height = if narrow_mode { 7 } else { 2 };
let content_area = Rect::new(
inner_area.x,
inner_area.y + search_header_height,
inner_area.width,
inner_area
.height
.saturating_sub(search_header_height + footer_height),
);
let mut layout = SettingsLayout::new(modal_area);
if narrow_mode {
render_vertical_layout(frame, content_area, modal_area, state, theme, &mut layout);
} else {
render_horizontal_layout(frame, content_area, modal_area, state, theme, &mut layout);
}
let has_confirm = state.showing_confirm_dialog;
let has_entry = state.showing_entry_dialog();
let has_help = state.showing_help;
if has_confirm {
if !has_entry && !has_help {
crate::view::dimming::apply_dimming(frame, modal_area);
}
render_confirm_dialog(frame, modal_area, state, theme);
}
if has_entry {
if !has_help {
crate::view::dimming::apply_dimming(frame, modal_area);
}
render_entry_dialog(frame, modal_area, state, theme);
}
if has_help {
crate::view::dimming::apply_dimming(frame, modal_area);
render_help_overlay(frame, modal_area, theme);
}
layout
}
fn render_horizontal_layout(
frame: &mut Frame,
content_area: Rect,
modal_area: Rect,
state: &mut SettingsState,
theme: &Theme,
layout: &mut SettingsLayout,
) {
let chunks =
Layout::horizontal([Constraint::Length(20), Constraint::Min(40)]).split(content_area);
let categories_area = chunks[0];
let settings_area = chunks[1];
render_categories(frame, categories_area, state, theme, layout);
let separator_area = Rect::new(
categories_area.x + categories_area.width,
categories_area.y,
1,
categories_area.height,
);
render_separator_with_selection(
frame,
separator_area,
theme,
state.selected_category,
state.pages.len(),
);
let horizontal_padding = 2;
let settings_inner = Rect::new(
settings_area.x + horizontal_padding,
settings_area.y,
settings_area.width.saturating_sub(horizontal_padding),
settings_area.height,
);
if state.search_active && !state.search_results.is_empty() {
render_search_results(frame, settings_inner, state, theme, layout);
} else {
render_settings_panel(frame, settings_inner, state, theme, layout);
}
render_footer(frame, modal_area, state, theme, layout, false);
}
fn render_vertical_layout(
frame: &mut Frame,
content_area: Rect,
modal_area: Rect,
state: &mut SettingsState,
theme: &Theme,
layout: &mut SettingsLayout,
) {
let footer_height = 7;
let main_height = content_area.height.saturating_sub(footer_height);
let category_height = 3u16.min(main_height);
let settings_height = main_height.saturating_sub(category_height + 1);
let categories_area = Rect::new(
content_area.x,
content_area.y,
content_area.width,
category_height,
);
let sep_y = content_area.y + category_height;
let settings_area = Rect::new(
content_area.x,
sep_y + 1,
content_area.width,
settings_height,
);
render_categories_horizontal(frame, categories_area, state, theme, layout);
if sep_y < content_area.y + content_area.height {
let sep_line: String = "─".repeat(content_area.width as usize);
frame.render_widget(
Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
Rect::new(content_area.x, sep_y, content_area.width, 1),
);
}
if state.search_active && !state.search_results.is_empty() {
render_search_results(frame, settings_area, state, theme, layout);
} else {
render_settings_panel(frame, settings_area, state, theme, layout);
}
render_footer(frame, modal_area, state, theme, layout, true);
}
fn render_categories_horizontal(
frame: &mut Frame,
area: Rect,
state: &SettingsState,
theme: &Theme,
layout: &mut SettingsLayout,
) {
use super::state::FocusPanel;
if area.height == 0 || area.width == 0 {
return;
}
let is_focused = state.focus_panel() == FocusPanel::Categories;
let mut spans = Vec::new();
let mut total_width = 0u16;
for (i, page) in state.pages.iter().enumerate() {
let is_selected = i == state.selected_category;
let has_modified = page.items.iter().any(|item| item.modified);
let indicator = if has_modified { "● " } else { " " };
let name = &page.name;
let style = if is_selected && is_focused {
Style::default()
.fg(theme.menu_highlight_fg)
.bg(theme.menu_highlight_bg)
.add_modifier(Modifier::BOLD)
} else if is_selected {
Style::default()
.fg(theme.menu_highlight_fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.popup_text_fg)
};
let indicator_style = if has_modified {
Style::default().fg(theme.menu_highlight_fg)
} else {
style
};
if i > 0 {
spans.push(Span::styled(
" │ ",
Style::default().fg(theme.split_separator_fg),
));
total_width += 3;
}
spans.push(Span::styled(indicator, indicator_style));
spans.push(Span::styled(name.as_str(), style));
total_width += (indicator.len() + name.len()) as u16;
let cat_x = area.x + total_width.saturating_sub((indicator.len() + name.len()) as u16);
let cat_width = (indicator.len() + name.len()) as u16;
layout
.categories
.push((i, Rect::new(cat_x, area.y, cat_width, 1)));
}
let line = Line::from(spans);
frame.render_widget(Paragraph::new(line), area);
if area.height >= 2 {
let hint = "←→: Switch category";
let hint_style = Style::default().fg(theme.line_number_fg);
frame.render_widget(
Paragraph::new(hint).style(hint_style),
Rect::new(area.x, area.y + 1, area.width, 1),
);
}
}
fn render_categories(
frame: &mut Frame,
area: Rect,
state: &SettingsState,
theme: &Theme,
layout: &mut SettingsLayout,
) {
use super::layout::SettingsHit;
use super::state::FocusPanel;
for (idx, page) in state.pages.iter().enumerate() {
if idx as u16 >= area.height {
break;
}
let is_selected = idx == state.selected_category;
let is_hovered = matches!(state.hover_hit, Some(SettingsHit::Category(i)) if i == idx);
let row_area = Rect::new(area.x, area.y + idx as u16, area.width, 1);
layout.add_category(idx, row_area);
let style = if is_selected {
if state.focus_panel() == FocusPanel::Categories {
Style::default()
.fg(theme.menu_highlight_fg)
.bg(theme.menu_highlight_bg)
} else {
Style::default().fg(theme.menu_fg).bg(theme.selection_bg)
}
} else if is_hovered {
Style::default()
.fg(theme.menu_hover_fg)
.bg(theme.menu_hover_bg)
} else {
Style::default().fg(theme.popup_text_fg)
};
let has_changes = page.items.iter().any(|i| i.modified);
let modified_indicator = if has_changes { "●" } else { " " };
let selection_indicator = if is_selected && state.focus_panel() == FocusPanel::Categories {
">"
} else {
" "
};
let text = format!(
"{}{} {}",
selection_indicator, modified_indicator, page.name
);
let line = Line::from(Span::styled(text, style));
frame.render_widget(Paragraph::new(line), row_area);
}
}
fn render_separator_with_selection(
frame: &mut Frame,
area: Rect,
theme: &Theme,
selected_category: usize,
category_count: usize,
) {
let sep_style = Style::default().fg(theme.split_separator_fg);
let highlight_style = Style::default().fg(theme.menu_highlight_fg);
for y in 0..area.height {
let cell = Rect::new(area.x, area.y + y, 1, 1);
let row_idx = y as usize;
let (char, style) = if row_idx == selected_category && row_idx < category_count {
("┤", highlight_style)
} else {
("│", sep_style)
};
let sep = Paragraph::new(char).style(style);
frame.render_widget(sep, cell);
}
}
struct RenderContext {
selected_item: usize,
settings_focused: bool,
hover_hit: Option<SettingsHit>,
}
fn render_settings_panel(
frame: &mut Frame,
area: Rect,
state: &mut SettingsState,
theme: &Theme,
layout: &mut SettingsLayout,
) {
let page = match state.current_page() {
Some(p) => p,
None => return,
};
let mut y = area.y;
let header_start_y = y;
let title_style = Style::default()
.fg(theme.menu_active_fg)
.add_modifier(Modifier::BOLD);
let title = Line::from(Span::styled(&page.name, title_style));
frame.render_widget(Paragraph::new(title), Rect::new(area.x, y, area.width, 1));
y += 1;
if let Some(ref desc) = page.description {
let desc_style = Style::default().fg(theme.line_number_fg);
let desc_line = Line::from(Span::styled(desc, desc_style));
frame.render_widget(
Paragraph::new(desc_line),
Rect::new(area.x, y, area.width, 1),
);
y += 1;
}
y += 1;
let header_height = (y - header_start_y) as usize;
let items_start_y = y;
let available_height = area.height.saturating_sub(header_height as u16);
let page = state.pages.get(state.selected_category).unwrap();
state.scroll_panel.set_viewport(available_height);
state.scroll_panel.update_content_height(&page.items);
use super::state::FocusPanel;
let render_ctx = RenderContext {
selected_item: state.selected_item,
settings_focused: state.focus_panel() == FocusPanel::Settings,
hover_hit: state.hover_hit,
};
let items_area = Rect::new(area.x, items_start_y, area.width, available_height.max(1));
let page = state.pages.get(state.selected_category).unwrap();
let max_label_width = page
.items
.iter()
.filter_map(|item| {
match &item.control {
SettingControl::Toggle(s) => Some(s.label.len() as u16),
SettingControl::Number(s) => Some(s.label.len() as u16),
SettingControl::Dropdown(s) => Some(s.label.len() as u16),
SettingControl::Text(s) => Some(s.label.len() as u16),
_ => None,
}
})
.max();
let panel_layout = state.scroll_panel.render(
frame,
items_area,
&page.items,
|frame, info, item| {
render_setting_item_pure(
frame,
info.area,
item,
info.index,
info.skip_top,
&render_ctx,
theme,
max_label_width,
)
},
theme,
);
let page = state.pages.get(state.selected_category).unwrap();
for item_info in panel_layout.item_layouts {
layout.add_item(
item_info.index,
page.items[item_info.index].path.clone(),
item_info.area,
item_info.layout,
);
}
layout.settings_panel_area = Some(panel_layout.content_area);
if let Some(sb_area) = panel_layout.scrollbar_area {
layout.scrollbar_area = Some(sb_area);
}
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
if width == 0 || text.is_empty() {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current_line = String::new();
let mut current_len = 0;
for word in text.split_whitespace() {
let word_len = word.chars().count();
if current_len == 0 {
current_line = word.to_string();
current_len = word_len;
} else if current_len + 1 + word_len <= width {
current_line.push(' ');
current_line.push_str(word);
current_len += 1 + word_len;
} else {
lines.push(current_line);
current_line = word.to_string();
current_len = word_len;
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
#[allow(clippy::too_many_arguments)]
fn render_setting_item_pure(
frame: &mut Frame,
area: Rect,
item: &super::items::SettingItem,
idx: usize,
skip_top: u16,
ctx: &RenderContext,
theme: &Theme,
label_width: Option<u16>,
) -> ControlLayoutInfo {
use super::items::SECTION_HEADER_HEIGHT;
let (item_area, item_skip_top) = if item.is_section_start {
if let Some(ref section_name) = item.section {
let header_visible_start = skip_top.min(SECTION_HEADER_HEIGHT);
let header_visible_height = SECTION_HEADER_HEIGHT.saturating_sub(skip_top);
if header_visible_height > 0 && area.height > 0 {
let header_y = area.y;
let _header_area_height = header_visible_height.min(area.height);
if header_visible_start == 0 {
let header_style = Style::default()
.fg(theme.menu_active_fg)
.add_modifier(Modifier::BOLD);
let header_text = format!("── {} ", section_name);
let remaining = area.width.saturating_sub(header_text.len() as u16);
let full_header = format!("{}{}", header_text, "─".repeat(remaining as usize));
frame.render_widget(
Paragraph::new(full_header).style(header_style),
Rect::new(area.x, header_y, area.width, 1),
);
}
}
let item_y_offset = header_visible_height.min(area.height);
let item_area = Rect::new(
area.x,
area.y + item_y_offset,
area.width,
area.height.saturating_sub(item_y_offset),
);
let item_skip_top = skip_top.saturating_sub(SECTION_HEADER_HEIGHT);
(item_area, item_skip_top)
} else {
(area, skip_top)
}
} else {
(area, skip_top)
};
if item_area.height == 0 {
return ControlLayoutInfo::default();
}
let area = item_area;
let skip_top = item_skip_top;
let is_selected = ctx.settings_focused && idx == ctx.selected_item;
let is_item_hovered = match ctx.hover_hit {
Some(SettingsHit::Item(i)) => i == idx,
Some(SettingsHit::ControlToggle(i)) => i == idx,
Some(SettingsHit::ControlDecrement(i)) => i == idx,
Some(SettingsHit::ControlIncrement(i)) => i == idx,
Some(SettingsHit::ControlDropdown(i)) => i == idx,
Some(SettingsHit::ControlText(i)) => i == idx,
Some(SettingsHit::ControlTextListRow(i, _)) => i == idx,
Some(SettingsHit::ControlMapRow(i, _)) => i == idx,
_ => false,
};
let is_focused_or_hovered = is_selected || is_item_hovered;
let focus_indicator_width: u16 = 3;
let content_height = if is_focused_or_hovered {
item.content_height_expanded(area.width.saturating_sub(focus_indicator_width))
} else {
item.content_height()
};
let visible_content_height = content_height.saturating_sub(skip_top);
if is_focused_or_hovered {
let bg_style = if is_selected {
Style::default().bg(theme.settings_selected_bg)
} else {
Style::default().bg(theme.menu_hover_bg)
};
for row in 0..visible_content_height.min(area.height) {
let row_area = Rect::new(area.x, area.y + row, area.width, 1);
frame.render_widget(Paragraph::new("").style(bg_style), row_area);
}
}
if is_selected && skip_top == 0 {
let indicator_style = Style::default()
.fg(theme.settings_selected_fg)
.add_modifier(Modifier::BOLD);
frame.render_widget(
Paragraph::new(">").style(indicator_style),
Rect::new(area.x, area.y, 1, 1),
);
}
if item.modified && skip_top == 0 {
let modified_style = Style::default().fg(theme.settings_selected_fg);
frame.render_widget(
Paragraph::new("●").style(modified_style),
Rect::new(area.x + 1, area.y, 1, 1),
);
}
let control_height = item.control.control_height();
let visible_control_height = control_height.saturating_sub(skip_top);
let control_area = Rect::new(
area.x + focus_indicator_width,
area.y,
area.width.saturating_sub(focus_indicator_width),
visible_control_height.min(area.height),
);
let layout = render_control(
frame,
control_area,
&item.control,
&item.name,
skip_top,
theme,
label_width.map(|w| w.saturating_sub(focus_indicator_width)),
item.read_only,
);
let desc_start_row = control_height.saturating_sub(skip_top);
let layer_label = match item.layer_source {
crate::config_io::ConfigLayer::System => None, crate::config_io::ConfigLayer::User => Some("user"),
crate::config_io::ConfigLayer::Project => Some("project"),
crate::config_io::ConfigLayer::Session => Some("session"),
};
if let Some(ref description) = item.description {
if desc_start_row < area.height {
let desc_x = area.x + focus_indicator_width;
let desc_y = area.y + desc_start_row;
let desc_width = area.width.saturating_sub(focus_indicator_width);
let desc_style = Style::default().fg(theme.line_number_fg);
let max_width = desc_width.saturating_sub(2) as usize;
if is_focused_or_hovered && description.len() > max_width {
let wrapped_lines = wrap_text(description, max_width);
let available_rows = area.height.saturating_sub(desc_start_row) as usize;
for (i, line) in wrapped_lines.iter().take(available_rows).enumerate() {
frame.render_widget(
Paragraph::new(line.as_str()).style(desc_style),
Rect::new(desc_x, desc_y + i as u16, desc_width, 1),
);
}
} else {
let mut display_desc = if description.len() > max_width.saturating_sub(12) {
format!(
"{}...",
&description[..max_width.saturating_sub(15).max(10)]
)
} else {
description.clone()
};
if let Some(layer) = layer_label {
display_desc.push_str(&format!(" ({})", layer));
}
frame.render_widget(
Paragraph::new(display_desc).style(desc_style),
Rect::new(desc_x, desc_y, desc_width, 1),
);
}
}
} else if let Some(layer) = layer_label {
if desc_start_row < area.height && is_focused_or_hovered {
let desc_x = area.x + focus_indicator_width;
let desc_y = area.y + desc_start_row;
let desc_width = area.width.saturating_sub(focus_indicator_width);
let layer_style = Style::default().fg(theme.line_number_fg);
frame.render_widget(
Paragraph::new(format!("({})", layer)).style(layer_style),
Rect::new(desc_x, desc_y, desc_width, 1),
);
}
}
layout
}
#[allow(clippy::too_many_arguments)]
fn render_control(
frame: &mut Frame,
area: Rect,
control: &SettingControl,
name: &str,
skip_rows: u16,
theme: &Theme,
label_width: Option<u16>,
read_only: bool,
) -> ControlLayoutInfo {
match control {
SettingControl::Toggle(state) => {
if skip_rows > 0 {
return ControlLayoutInfo::Toggle(Rect::default());
}
let colors = ToggleColors::from_theme(theme);
let toggle_layout = render_toggle_aligned(frame, area, state, &colors, label_width);
ControlLayoutInfo::Toggle(toggle_layout.full_area)
}
SettingControl::Number(state) => {
if skip_rows > 0 {
return ControlLayoutInfo::Number {
decrement: Rect::default(),
increment: Rect::default(),
value: Rect::default(),
};
}
let colors = NumberInputColors::from_theme(theme);
let num_layout = render_number_input_aligned(frame, area, state, &colors, label_width);
ControlLayoutInfo::Number {
decrement: num_layout.decrement_area,
increment: num_layout.increment_area,
value: num_layout.value_area,
}
}
SettingControl::Dropdown(state) => {
if skip_rows > 0 {
return ControlLayoutInfo::Dropdown {
button_area: Rect::default(),
option_areas: Vec::new(),
scroll_offset: 0,
};
}
let colors = DropdownColors::from_theme(theme);
let drop_layout = render_dropdown_aligned(frame, area, state, &colors, label_width);
ControlLayoutInfo::Dropdown {
button_area: drop_layout.button_area,
option_areas: drop_layout.option_areas,
scroll_offset: drop_layout.scroll_offset,
}
}
SettingControl::Text(state) => {
if skip_rows > 0 {
return ControlLayoutInfo::Text(Rect::default());
}
if read_only {
let label_w = label_width.unwrap_or(20);
let label_style = Style::default().fg(theme.editor_fg);
let value_style = Style::default().fg(theme.line_number_fg);
let label = format!("{}: ", state.label);
let value = &state.value;
let label_area = Rect::new(area.x, area.y, label_w, 1);
let value_area = Rect::new(
area.x + label_w,
area.y,
area.width.saturating_sub(label_w),
1,
);
frame.render_widget(Paragraph::new(label.clone()).style(label_style), label_area);
frame.render_widget(
Paragraph::new(value.as_str()).style(value_style),
value_area,
);
ControlLayoutInfo::Text(Rect::default()) } else {
let colors = TextInputColors::from_theme(theme);
let text_layout =
render_text_input_aligned(frame, area, state, &colors, 30, label_width);
ControlLayoutInfo::Text(text_layout.input_area)
}
}
SettingControl::TextList(state) => {
let colors = TextListColors::from_theme(theme);
let list_layout = render_text_list_partial(frame, area, state, &colors, 30, skip_rows);
ControlLayoutInfo::TextList {
rows: list_layout.rows.iter().map(|r| r.text_area).collect(),
}
}
SettingControl::Map(state) => {
let colors = MapColors::from_theme(theme);
let map_layout = render_map_partial(frame, area, state, &colors, 20, skip_rows);
ControlLayoutInfo::Map {
entry_rows: map_layout.entry_areas.iter().map(|e| e.row_area).collect(),
add_row_area: map_layout.add_row_area,
}
}
SettingControl::ObjectArray(state) => {
let colors = crate::view::controls::KeybindingListColors {
label_fg: theme.editor_fg,
key_fg: theme.help_key_fg,
action_fg: theme.syntax_function,
focused_bg: theme.settings_selected_bg,
focused_fg: theme.settings_selected_fg,
delete_fg: theme.diagnostic_error_fg,
add_fg: theme.syntax_string,
};
let kb_layout = render_keybinding_list_partial(frame, area, state, &colors, skip_rows);
ControlLayoutInfo::ObjectArray {
entry_rows: kb_layout.entry_rects,
}
}
SettingControl::Json(state) => {
render_json_control(frame, area, state, name, skip_rows, theme)
}
SettingControl::Complex { type_name } => {
if skip_rows > 0 {
return ControlLayoutInfo::Complex;
}
let label_style = Style::default().fg(theme.editor_fg);
let value_style = Style::default().fg(theme.line_number_fg);
let label = Span::styled(format!("{}: ", name), label_style);
let value = Span::styled(
format!("<{} - edit in config.toml>", type_name),
value_style,
);
frame.render_widget(Paragraph::new(Line::from(vec![label, value])), area);
ControlLayoutInfo::Complex
}
}
}
fn render_json_control(
frame: &mut Frame,
area: Rect,
state: &super::items::JsonEditState,
name: &str,
skip_rows: u16,
theme: &Theme,
) -> ControlLayoutInfo {
use crate::view::controls::FocusState;
let empty_layout = ControlLayoutInfo::Json {
edit_area: Rect::default(),
};
if area.height == 0 || area.width < 10 {
return empty_layout;
}
let is_focused = state.focus == FocusState::Focused;
let is_valid = state.is_valid();
let label_color = if is_focused {
theme.menu_highlight_fg
} else {
theme.editor_fg
};
let text_color = theme.editor_fg;
let border_color = if !is_valid {
theme.diagnostic_error_fg
} else if is_focused {
theme.menu_highlight_fg
} else {
theme.split_separator_fg
};
let mut y = area.y;
let mut content_row = 0u16;
if content_row >= skip_rows {
let label_line = Line::from(vec![Span::styled(
format!("{}:", name),
Style::default().fg(label_color),
)]);
frame.render_widget(
Paragraph::new(label_line),
Rect::new(area.x, y, area.width, 1),
);
y += 1;
}
content_row += 1;
let indent = 2u16;
let edit_width = area.width.saturating_sub(indent + 1);
let edit_x = area.x + indent;
let edit_start_y = y;
let lines = state.lines();
let total_lines = lines.len();
for line_idx in 0..total_lines {
let actual_line_idx = line_idx;
if content_row < skip_rows {
content_row += 1;
continue;
}
if y >= area.y + area.height {
break;
}
let line_content = lines.get(actual_line_idx).map(|s| s.as_str()).unwrap_or("");
let display_len = edit_width.saturating_sub(2) as usize;
let display_text: String = line_content.chars().take(display_len).collect();
let selection = state.selection_range();
let (cursor_row, cursor_col) = state.cursor_pos();
let content_spans = if is_focused {
if let Some(((start_row, start_col), (end_row, end_col))) = selection {
build_selection_spans(
&display_text,
display_len,
actual_line_idx,
start_row,
start_col,
end_row,
end_col,
text_color,
theme.selection_bg,
)
} else {
vec![Span::styled(
format!("{:width$}", display_text, width = display_len),
Style::default().fg(text_color),
)]
}
} else {
vec![Span::styled(
format!("{:width$}", display_text, width = display_len),
Style::default().fg(text_color),
)]
};
let mut spans = vec![
Span::raw(" ".repeat(indent as usize)),
Span::styled("│", Style::default().fg(border_color)),
];
spans.extend(content_spans);
spans.push(Span::styled("│", Style::default().fg(border_color)));
let line = Line::from(spans);
frame.render_widget(Paragraph::new(line), Rect::new(area.x, y, area.width, 1));
if is_focused && actual_line_idx == cursor_row {
let cursor_x = edit_x + 1 + cursor_col.min(display_len) as u16;
if cursor_x < area.x + area.width - 1 {
let cursor_char = line_content.chars().nth(cursor_col).unwrap_or(' ');
let cursor_span = Span::styled(
cursor_char.to_string(),
Style::default()
.fg(theme.cursor)
.add_modifier(Modifier::REVERSED),
);
frame.render_widget(
Paragraph::new(Line::from(vec![cursor_span])),
Rect::new(cursor_x, y, 1, 1),
);
}
}
y += 1;
content_row += 1;
}
if !is_valid && y < area.y + area.height {
let warning = Span::styled(
" ⚠ Invalid JSON",
Style::default().fg(theme.diagnostic_warning_fg),
);
frame.render_widget(
Paragraph::new(Line::from(vec![warning])),
Rect::new(area.x, y, area.width, 1),
);
}
let edit_height = y.saturating_sub(edit_start_y);
ControlLayoutInfo::Json {
edit_area: Rect::new(edit_x, edit_start_y, edit_width, edit_height),
}
}
fn render_text_list_partial(
frame: &mut Frame,
area: Rect,
state: &crate::view::controls::TextListState,
colors: &TextListColors,
field_width: u16,
skip_rows: u16,
) -> crate::view::controls::TextListLayout {
use crate::view::controls::text_list::{TextListLayout, TextListRowLayout};
use crate::view::controls::FocusState;
let empty_layout = TextListLayout {
rows: Vec::new(),
full_area: area,
};
if area.height == 0 || area.width < 10 {
return empty_layout;
}
let label_color = match state.focus {
FocusState::Focused => colors.focused_fg,
FocusState::Hovered => colors.focused_fg,
FocusState::Disabled => colors.disabled,
FocusState::Normal => colors.label,
};
let mut rows = Vec::new();
let mut y = area.y;
let mut content_row = 0u16;
if skip_rows == 0 {
let label_line = Line::from(vec![
Span::styled(&state.label, Style::default().fg(label_color)),
Span::raw(":"),
]);
frame.render_widget(
Paragraph::new(label_line),
Rect::new(area.x, y, area.width, 1),
);
y += 1;
}
content_row += 1;
let indent = 2u16;
let actual_field_width = field_width.min(area.width.saturating_sub(indent + 5));
for (idx, item) in state.items.iter().enumerate() {
if y >= area.y + area.height {
break;
}
if content_row < skip_rows {
content_row += 1;
continue;
}
let is_focused = state.focused_item == Some(idx) && state.focus == FocusState::Focused;
let (border_color, text_color) = if is_focused {
(colors.focused, colors.text)
} else if state.focus == FocusState::Disabled {
(colors.disabled, colors.disabled)
} else {
(colors.border, colors.text)
};
let inner_width = actual_field_width.saturating_sub(2) as usize;
let visible: String = item.chars().take(inner_width).collect();
let padded = format!("{:width$}", visible, width = inner_width);
let line = Line::from(vec![
Span::raw(" ".repeat(indent as usize)),
Span::styled("[", Style::default().fg(border_color)),
Span::styled(padded, Style::default().fg(text_color)),
Span::styled("]", Style::default().fg(border_color)),
Span::raw(" "),
Span::styled("[x]", Style::default().fg(colors.remove_button)),
]);
let row_area = Rect::new(area.x, y, area.width, 1);
frame.render_widget(Paragraph::new(line), row_area);
let text_area = Rect::new(area.x + indent, y, actual_field_width, 1);
let button_area = Rect::new(area.x + indent + actual_field_width + 1, y, 3, 1);
rows.push(TextListRowLayout {
text_area,
button_area,
index: Some(idx),
});
y += 1;
content_row += 1;
}
if y < area.y + area.height && content_row >= skip_rows {
let is_add_focused = state.focused_item.is_none() && state.focus == FocusState::Focused;
if is_add_focused {
let inner_width = actual_field_width.saturating_sub(2) as usize;
let visible: String = state.new_item_text.chars().take(inner_width).collect();
let padded = format!("{:width$}", visible, width = inner_width);
let line = Line::from(vec![
Span::raw(" ".repeat(indent as usize)),
Span::styled("[", Style::default().fg(colors.focused)),
Span::styled(padded, Style::default().fg(colors.text)),
Span::styled("]", Style::default().fg(colors.focused)),
Span::raw(" "),
Span::styled("[+]", Style::default().fg(colors.add_button)),
]);
let row_area = Rect::new(area.x, y, area.width, 1);
frame.render_widget(Paragraph::new(line), row_area);
if state.cursor <= inner_width {
let cursor_x = area.x + indent + 1 + state.cursor as u16;
let cursor_char = state.new_item_text.chars().nth(state.cursor).unwrap_or(' ');
let cursor_area = Rect::new(cursor_x, y, 1, 1);
let cursor_span = Span::styled(
cursor_char.to_string(),
Style::default()
.fg(colors.focused)
.add_modifier(ratatui::style::Modifier::REVERSED),
);
frame.render_widget(Paragraph::new(Line::from(vec![cursor_span])), cursor_area);
}
rows.push(TextListRowLayout {
text_area: Rect::new(area.x + indent, y, actual_field_width, 1),
button_area: Rect::new(area.x + indent + actual_field_width + 1, y, 3, 1),
index: None,
});
} else {
let add_line = Line::from(vec![
Span::raw(" ".repeat(indent as usize)),
Span::styled("[+] Add new", Style::default().fg(colors.add_button)),
]);
let row_area = Rect::new(area.x, y, area.width, 1);
frame.render_widget(Paragraph::new(add_line), row_area);
rows.push(TextListRowLayout {
text_area: Rect::new(area.x + indent, y, 11, 1), button_area: Rect::new(area.x + indent, y, 11, 1),
index: None,
});
}
}
TextListLayout {
rows,
full_area: area,
}
}
fn render_map_partial(
frame: &mut Frame,
area: Rect,
state: &crate::view::controls::MapState,
colors: &MapColors,
key_width: u16,
skip_rows: u16,
) -> crate::view::controls::MapLayout {
use crate::view::controls::map_input::{MapEntryLayout, MapLayout};
use crate::view::controls::FocusState;
let empty_layout = MapLayout {
entry_areas: Vec::new(),
add_row_area: None,
full_area: area,
};
if area.height == 0 || area.width < 15 {
return empty_layout;
}
let label_color = match state.focus {
FocusState::Focused => colors.focused_fg,
FocusState::Hovered => colors.focused_fg,
FocusState::Disabled => colors.disabled,
FocusState::Normal => colors.label,
};
let mut entry_areas = Vec::new();
let mut y = area.y;
let mut content_row = 0u16;
if skip_rows == 0 {
let label_line = Line::from(vec![
Span::styled(&state.label, Style::default().fg(label_color)),
Span::raw(":"),
]);
frame.render_widget(
Paragraph::new(label_line),
Rect::new(area.x, y, area.width, 1),
);
y += 1;
}
content_row += 1;
let indent = 2u16;
if state.display_field.is_some() && y < area.y + area.height {
if content_row >= skip_rows {
let value_header = state
.display_field
.as_ref()
.map(|f| {
let name = f.trim_start_matches('/');
let mut chars = name.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.unwrap_or_else(|| "Value".to_string());
let header_style = Style::default()
.fg(colors.label)
.add_modifier(Modifier::DIM);
let header_line = Line::from(vec![
Span::styled(" ".repeat(indent as usize), header_style),
Span::styled(
format!("{:width$}", "Name", width = key_width as usize),
header_style,
),
Span::raw(" "),
Span::styled(value_header, header_style),
]);
frame.render_widget(
Paragraph::new(header_line),
Rect::new(area.x, y, area.width, 1),
);
y += 1;
}
content_row += 1;
}
for (idx, (key, value)) in state.entries.iter().enumerate() {
if y >= area.y + area.height {
break;
}
if content_row < skip_rows {
content_row += 1;
continue;
}
let is_focused = state.focused_entry == Some(idx) && state.focus == FocusState::Focused;
let row_area = Rect::new(area.x, y, area.width, 1);
if is_focused {
let highlight_style = Style::default().bg(colors.focused);
let bg_line = Line::from(Span::styled(
" ".repeat(area.width as usize),
highlight_style,
));
frame.render_widget(Paragraph::new(bg_line), row_area);
}
let (key_color, value_color) = if is_focused {
(colors.focused_fg, colors.focused_fg)
} else if state.focus == FocusState::Disabled {
(colors.disabled, colors.disabled)
} else {
(colors.key, colors.value_preview)
};
let base_style = if is_focused {
Style::default().bg(colors.focused)
} else {
Style::default()
};
let value_preview = state.get_display_value(value);
let max_preview_len = 20;
let value_preview = if value_preview.len() > max_preview_len {
format!("{}...", &value_preview[..max_preview_len - 3])
} else {
value_preview
};
let display_key: String = key.chars().take(key_width as usize).collect();
let mut spans = vec![
Span::styled(" ".repeat(indent as usize), base_style),
Span::styled(
format!("{:width$}", display_key, width = key_width as usize),
base_style.fg(key_color),
),
Span::raw(" "),
Span::styled(value_preview, base_style.fg(value_color)),
];
if is_focused {
spans.push(Span::styled(
" [Enter to edit]",
base_style.fg(colors.focused_fg).add_modifier(Modifier::DIM),
));
}
frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
entry_areas.push(MapEntryLayout {
index: idx,
row_area,
expand_area: Rect::default(), key_area: Rect::new(area.x + indent, y, key_width, 1),
remove_area: Rect::new(area.x + indent + key_width + 1, y, 3, 1),
});
y += 1;
content_row += 1;
}
let add_row_area = if !state.no_add && y < area.y + area.height && content_row >= skip_rows {
let row_area = Rect::new(area.x, y, area.width, 1);
let is_focused = state.focused_entry.is_none() && state.focus == FocusState::Focused;
if is_focused {
let highlight_style = Style::default().bg(colors.focused);
let bg_line = Line::from(Span::styled(
" ".repeat(area.width as usize),
highlight_style,
));
frame.render_widget(Paragraph::new(bg_line), row_area);
}
let base_style = if is_focused {
Style::default().bg(colors.focused)
} else {
Style::default()
};
let mut spans = vec![
Span::styled(" ".repeat(indent as usize), base_style),
Span::styled("[+] Add new", base_style.fg(colors.add_button)),
];
if is_focused {
spans.push(Span::styled(
" [Enter to add]",
base_style.fg(colors.focused_fg).add_modifier(Modifier::DIM),
));
}
frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
Some(row_area)
} else {
None
};
MapLayout {
entry_areas,
add_row_area,
full_area: area,
}
}
fn render_keybinding_list_partial(
frame: &mut Frame,
area: Rect,
state: &crate::view::controls::KeybindingListState,
colors: &crate::view::controls::KeybindingListColors,
skip_rows: u16,
) -> crate::view::controls::KeybindingListLayout {
use crate::view::controls::keybinding_list::format_key_combo;
use crate::view::controls::FocusState;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
let empty_layout = crate::view::controls::KeybindingListLayout {
entry_rects: Vec::new(),
delete_rects: Vec::new(),
add_rect: None,
};
if area.height == 0 {
return empty_layout;
}
let indent = 2u16;
let is_focused = state.focus == FocusState::Focused;
let mut entry_rects = Vec::new();
let mut delete_rects = Vec::new();
let mut content_row = 0u16;
let mut y = area.y;
if content_row >= skip_rows {
let label_line = Line::from(vec![Span::styled(
format!("{}:", state.label),
Style::default().fg(colors.label_fg),
)]);
frame.render_widget(
Paragraph::new(label_line),
Rect::new(area.x, y, area.width, 1),
);
y += 1;
}
content_row += 1;
for (idx, binding) in state.bindings.iter().enumerate() {
if y >= area.y + area.height {
break;
}
if content_row >= skip_rows {
let entry_area = Rect::new(area.x + indent, y, area.width.saturating_sub(indent), 1);
entry_rects.push(entry_area);
let is_entry_focused = is_focused && state.focused_index == Some(idx);
let bg = if is_entry_focused {
colors.focused_bg
} else {
Color::Reset
};
let key_combo = format_key_combo(binding);
let field_name = state
.display_field
.as_ref()
.and_then(|p| p.strip_prefix('/'))
.unwrap_or("action");
let action = binding
.get(field_name)
.and_then(|a| a.as_str())
.unwrap_or("(no action)");
let indicator = if is_entry_focused { "> " } else { " " };
let (indicator_fg, key_fg, arrow_fg, action_fg, delete_fg) = if is_entry_focused {
(
colors.focused_fg,
colors.focused_fg,
colors.focused_fg,
colors.focused_fg,
colors.focused_fg,
)
} else {
(
colors.label_fg,
colors.key_fg,
colors.label_fg,
colors.action_fg,
colors.delete_fg,
)
};
let line = Line::from(vec![
Span::styled(indicator, Style::default().fg(indicator_fg).bg(bg)),
Span::styled(
format!("{:<20}", key_combo),
Style::default().fg(key_fg).bg(bg),
),
Span::styled(" → ", Style::default().fg(arrow_fg).bg(bg)),
Span::styled(action, Style::default().fg(action_fg).bg(bg)),
Span::styled(" [x]", Style::default().fg(delete_fg).bg(bg)),
]);
frame.render_widget(Paragraph::new(line), entry_area);
let delete_x = entry_area.x + entry_area.width.saturating_sub(4);
delete_rects.push(Rect::new(delete_x, y, 3, 1));
y += 1;
}
content_row += 1;
}
let add_rect = if y < area.y + area.height && content_row >= skip_rows {
let is_add_focused = is_focused && state.focused_index.is_none();
let bg = if is_add_focused {
colors.focused_bg
} else {
Color::Reset
};
let indicator = if is_add_focused { "> " } else { " " };
let (indicator_fg, add_fg) = if is_add_focused {
(colors.focused_fg, colors.focused_fg)
} else {
(colors.label_fg, colors.add_fg)
};
let line = Line::from(vec![
Span::styled(indicator, Style::default().fg(indicator_fg).bg(bg)),
Span::styled("[+] Add new", Style::default().fg(add_fg).bg(bg)),
]);
let add_area = Rect::new(area.x + indent, y, area.width.saturating_sub(indent), 1);
frame.render_widget(Paragraph::new(line), add_area);
Some(add_area)
} else {
None
};
crate::view::controls::KeybindingListLayout {
entry_rects,
delete_rects,
add_rect,
}
}
#[derive(Debug, Clone, Default)]
pub enum ControlLayoutInfo {
Toggle(Rect),
Number {
decrement: Rect,
increment: Rect,
value: Rect,
},
Dropdown {
button_area: Rect,
option_areas: Vec<Rect>,
scroll_offset: usize,
},
Text(Rect),
TextList {
rows: Vec<Rect>,
},
Map {
entry_rows: Vec<Rect>,
add_row_area: Option<Rect>,
},
ObjectArray {
entry_rows: Vec<Rect>,
},
Json {
edit_area: Rect,
},
#[default]
Complex,
}
#[allow(clippy::too_many_arguments)]
fn render_button(
frame: &mut Frame,
area: Rect,
text: &str,
focused_text: &str,
is_focused: bool,
is_hovered: bool,
theme: &Theme,
dimmed: bool,
) {
if is_focused {
let style = Style::default()
.fg(theme.menu_highlight_fg)
.bg(theme.menu_highlight_bg)
.add_modifier(Modifier::BOLD);
frame.render_widget(Paragraph::new(focused_text).style(style), area);
} else if is_hovered {
let style = Style::default()
.fg(theme.menu_hover_fg)
.bg(theme.menu_hover_bg);
frame.render_widget(Paragraph::new(text).style(style), area);
} else {
let fg = if dimmed {
theme.line_number_fg
} else {
theme.popup_text_fg
};
frame.render_widget(Paragraph::new(text).style(Style::default().fg(fg)), area);
}
}
fn render_footer(
frame: &mut Frame,
modal_area: Rect,
state: &SettingsState,
theme: &Theme,
layout: &mut SettingsLayout,
vertical: bool,
) {
use super::layout::SettingsHit;
use super::state::FocusPanel;
if modal_area.height < 4 || modal_area.width < 10 {
return;
}
if vertical {
render_footer_vertical(frame, modal_area, state, theme, layout);
return;
}
let footer_y = modal_area.y + modal_area.height.saturating_sub(2);
let footer_width = modal_area.width.saturating_sub(2);
let footer_area = Rect::new(modal_area.x + 1, footer_y, footer_width, 1);
if footer_y > modal_area.y {
let sep_y = footer_y.saturating_sub(1);
let sep_area = Rect::new(modal_area.x + 1, sep_y, footer_width, 1);
let sep_line: String = "─".repeat(sep_area.width as usize);
frame.render_widget(
Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
sep_area,
);
}
let footer_focused = state.focus_panel() == FocusPanel::Footer;
let layer_hovered = matches!(state.hover_hit, Some(SettingsHit::LayerButton));
let reset_hovered = matches!(state.hover_hit, Some(SettingsHit::ResetButton));
let save_hovered = matches!(state.hover_hit, Some(SettingsHit::SaveButton));
let cancel_hovered = matches!(state.hover_hit, Some(SettingsHit::CancelButton));
let edit_hovered = matches!(state.hover_hit, Some(SettingsHit::EditButton));
let layer_focused = footer_focused && state.footer_button_index == 0;
let reset_focused = footer_focused && state.footer_button_index == 1;
let save_focused = footer_focused && state.footer_button_index == 2;
let cancel_focused = footer_focused && state.footer_button_index == 3;
let edit_focused = footer_focused && state.footer_button_index == 4;
let save_label = t!("settings.btn_save").to_string();
let cancel_label = t!("settings.btn_cancel").to_string();
let reset_label = t!("settings.btn_reset").to_string();
let edit_label = t!("settings.btn_edit").to_string();
let layer_text = format!("[ {} ]", state.target_layer_name());
let layer_text_focused = format!(">[ {} ]", state.target_layer_name());
let save_text = format!("[ {} ]", save_label);
let save_text_focused = format!(">[ {} ]", save_label);
let cancel_text = format!("[ {} ]", cancel_label);
let cancel_text_focused = format!(">[ {} ]", cancel_label);
let reset_text = format!("[ {} ]", reset_label);
let reset_text_focused = format!(">[ {} ]", reset_label);
let edit_text = format!("[ {} ]", edit_label);
let edit_text_focused = format!(">[ {} ]", edit_label);
let cancel_width = str_width(if cancel_focused {
&cancel_text_focused
} else {
&cancel_text
}) as u16;
let save_width = str_width(if save_focused {
&save_text_focused
} else {
&save_text
}) as u16;
let reset_width = str_width(if reset_focused {
&reset_text_focused
} else {
&reset_text
}) as u16;
let layer_width = str_width(if layer_focused {
&layer_text_focused
} else {
&layer_text
}) as u16;
let edit_width = str_width(if edit_focused {
&edit_text_focused
} else {
&edit_text
}) as u16;
let gap: u16 = 2;
let min_buttons_width = save_width + gap + cancel_width;
let all_buttons_width =
edit_width + gap + layer_width + gap + reset_width + gap + save_width + gap + cancel_width;
let available = footer_area.width;
let show_edit = available >= all_buttons_width;
let show_layer = available >= (layer_width + gap + reset_width + gap + min_buttons_width);
let show_reset = available >= (reset_width + gap + min_buttons_width);
let cancel_x = footer_area
.x
.saturating_add(footer_area.width.saturating_sub(cancel_width));
let save_x = cancel_x.saturating_sub(save_width + gap);
let reset_x = if show_reset {
save_x.saturating_sub(reset_width + gap)
} else {
0
};
let layer_x = if show_layer {
reset_x.saturating_sub(layer_width + gap)
} else {
0
};
let edit_x = footer_area.x;
if show_layer {
let layer_area = Rect::new(layer_x, footer_y, layer_width, 1);
render_button(
frame,
layer_area,
&layer_text,
&layer_text_focused,
layer_focused,
layer_hovered,
theme,
false,
);
layout.layer_button = Some(layer_area);
}
if show_reset {
let reset_area = Rect::new(reset_x, footer_y, reset_width, 1);
render_button(
frame,
reset_area,
&reset_text,
&reset_text_focused,
reset_focused,
reset_hovered,
theme,
false,
);
layout.reset_button = Some(reset_area);
}
let save_area = Rect::new(save_x, footer_y, save_width, 1);
render_button(
frame,
save_area,
&save_text,
&save_text_focused,
save_focused,
save_hovered,
theme,
false,
);
layout.save_button = Some(save_area);
let cancel_area = Rect::new(cancel_x, footer_y, cancel_width, 1);
render_button(
frame,
cancel_area,
&cancel_text,
&cancel_text_focused,
cancel_focused,
cancel_hovered,
theme,
false,
);
layout.cancel_button = Some(cancel_area);
if show_edit {
let edit_area = Rect::new(edit_x, footer_y, edit_width, 1);
render_button(
frame,
edit_area,
&edit_text,
&edit_text_focused,
edit_focused,
edit_hovered,
theme,
true, );
layout.edit_button = Some(edit_area);
}
let help_start_x = if show_edit {
edit_x + edit_width + 2
} else {
footer_area.x
};
let help_end_x = if show_layer {
layer_x
} else if show_reset {
reset_x
} else {
save_x
};
let help_width = help_end_x.saturating_sub(help_start_x + 1);
let help = if state.search_active {
t!("settings.help_search").to_string()
} else if footer_focused {
t!("settings.help_footer").to_string()
} else {
t!("settings.help_default").to_string()
};
let help_style = Style::default().fg(theme.line_number_fg);
frame.render_widget(
Paragraph::new(help.as_str()).style(help_style),
Rect::new(help_start_x, footer_y, help_width, 1),
);
}
fn render_footer_vertical(
frame: &mut Frame,
modal_area: Rect,
state: &SettingsState,
theme: &Theme,
layout: &mut SettingsLayout,
) {
use super::layout::SettingsHit;
use super::state::FocusPanel;
let footer_height = 7u16;
let footer_y = modal_area
.y
.saturating_add(modal_area.height.saturating_sub(footer_height));
let footer_width = modal_area.width.saturating_sub(2);
let sep_y = footer_y;
if sep_y > modal_area.y {
let sep_line: String = "─".repeat(footer_width as usize);
frame.render_widget(
Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
Rect::new(modal_area.x + 1, sep_y, footer_width, 1),
);
}
let footer_focused = state.focus_panel() == FocusPanel::Footer;
let layer_hovered = matches!(state.hover_hit, Some(SettingsHit::LayerButton));
let reset_hovered = matches!(state.hover_hit, Some(SettingsHit::ResetButton));
let save_hovered = matches!(state.hover_hit, Some(SettingsHit::SaveButton));
let cancel_hovered = matches!(state.hover_hit, Some(SettingsHit::CancelButton));
let edit_hovered = matches!(state.hover_hit, Some(SettingsHit::EditButton));
let layer_focused = footer_focused && state.footer_button_index == 0;
let reset_focused = footer_focused && state.footer_button_index == 1;
let save_focused = footer_focused && state.footer_button_index == 2;
let cancel_focused = footer_focused && state.footer_button_index == 3;
let edit_focused = footer_focused && state.footer_button_index == 4;
let save_label = t!("settings.btn_save").to_string();
let cancel_label = t!("settings.btn_cancel").to_string();
let reset_label = t!("settings.btn_reset").to_string();
let edit_label = t!("settings.btn_edit").to_string();
let layer_text = format!("[ {} ]", state.target_layer_name());
let layer_text_focused = format!(">[ {} ]", state.target_layer_name());
let save_text = format!("[ {} ]", save_label);
let save_text_focused = format!(">[ {} ]", save_label);
let cancel_text = format!("[ {} ]", cancel_label);
let cancel_text_focused = format!(">[ {} ]", cancel_label);
let reset_text = format!("[ {} ]", reset_label);
let reset_text_focused = format!(">[ {} ]", reset_label);
let edit_text = format!("[ {} ]", edit_label);
let edit_text_focused = format!(">[ {} ]", edit_label);
let button_x = modal_area.x + 2;
let mut y = sep_y + 1;
let layer_width = str_width(if layer_focused {
&layer_text_focused
} else {
&layer_text
}) as u16;
let layer_area = Rect::new(button_x, y, layer_width.min(footer_width), 1);
render_button(
frame,
layer_area,
&layer_text,
&layer_text_focused,
layer_focused,
layer_hovered,
theme,
false,
);
layout.layer_button = Some(layer_area);
y += 1;
let save_width = str_width(if save_focused {
&save_text_focused
} else {
&save_text
}) as u16;
let save_area = Rect::new(button_x, y, save_width.min(footer_width), 1);
render_button(
frame,
save_area,
&save_text,
&save_text_focused,
save_focused,
save_hovered,
theme,
false,
);
layout.save_button = Some(save_area);
y += 1;
let reset_width = str_width(if reset_focused {
&reset_text_focused
} else {
&reset_text
}) as u16;
let reset_area = Rect::new(button_x, y, reset_width.min(footer_width), 1);
render_button(
frame,
reset_area,
&reset_text,
&reset_text_focused,
reset_focused,
reset_hovered,
theme,
false,
);
layout.reset_button = Some(reset_area);
y += 1;
let cancel_width = str_width(if cancel_focused {
&cancel_text_focused
} else {
&cancel_text
}) as u16;
let cancel_area = Rect::new(button_x, y, cancel_width.min(footer_width), 1);
render_button(
frame,
cancel_area,
&cancel_text,
&cancel_text_focused,
cancel_focused,
cancel_hovered,
theme,
false,
);
layout.cancel_button = Some(cancel_area);
y += 1;
let edit_width = str_width(if edit_focused {
&edit_text_focused
} else {
&edit_text
}) as u16;
let edit_area = Rect::new(button_x, y, edit_width.min(footer_width), 1);
render_button(
frame,
edit_area,
&edit_text,
&edit_text_focused,
edit_focused,
edit_hovered,
theme,
true, );
layout.edit_button = Some(edit_area);
}
fn render_search_header(frame: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
let search_style = Style::default().fg(theme.settings_selected_fg);
let cursor_style = Style::default()
.fg(theme.settings_selected_fg)
.add_modifier(Modifier::REVERSED);
let result_count = state.search_results.len();
let count_text = if state.search_query.is_empty() {
String::new()
} else if result_count == 0 {
" (no results)".to_string()
} else if result_count == 1 {
" (1 result)".to_string()
} else {
format!(" ({} results)", result_count)
};
let count_style = Style::default().fg(theme.line_number_fg);
let spans = vec![
Span::styled("> ", search_style),
Span::styled(&state.search_query, search_style),
Span::styled(" ", cursor_style), Span::styled(count_text, count_style),
];
let line = Line::from(spans);
frame.render_widget(Paragraph::new(line), area);
}
fn render_search_hint(frame: &mut Frame, area: Rect, theme: &Theme) {
let hint_style = Style::default().fg(theme.line_number_fg);
let key_style = Style::default()
.fg(theme.menu_active_fg)
.add_modifier(Modifier::BOLD);
let spans = vec![
Span::styled("Press ", hint_style),
Span::styled("/", key_style),
Span::styled(" to search settings...", hint_style),
];
let line = Line::from(spans);
frame.render_widget(Paragraph::new(line), area);
}
fn render_search_results(
frame: &mut Frame,
area: Rect,
state: &SettingsState,
theme: &Theme,
layout: &mut SettingsLayout,
) {
let mut y = area.y;
for (idx, result) in state.search_results.iter().enumerate() {
if y >= area.y + area.height.saturating_sub(3) {
break;
}
let is_selected = idx == state.selected_search_result;
let item_area = Rect::new(area.x, y, area.width, 3);
render_search_result_item(frame, item_area, result, is_selected, theme, layout);
y += 3;
}
}
fn render_search_result_item(
frame: &mut Frame,
area: Rect,
result: &SearchResult,
is_selected: bool,
theme: &Theme,
layout: &mut SettingsLayout,
) {
if is_selected {
let bg_style = Style::default().bg(theme.settings_selected_bg);
for row in 0..area.height.min(3) {
let row_area = Rect::new(area.x, area.y + row, area.width, 1);
frame.render_widget(Paragraph::new("").style(bg_style), row_area);
}
}
let name_style = if is_selected {
Style::default().fg(theme.settings_selected_fg)
} else {
Style::default().fg(theme.popup_text_fg)
};
let name_line = build_highlighted_text(
&result.item.name,
&result.name_matches,
name_style,
Style::default()
.fg(theme.diagnostic_warning_fg)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(
Paragraph::new(name_line),
Rect::new(area.x, area.y, area.width, 1),
);
let breadcrumb_style = Style::default()
.fg(theme.line_number_fg)
.add_modifier(Modifier::ITALIC);
let breadcrumb = format!(" {} > {}", result.breadcrumb, result.item.path);
let breadcrumb_line = Line::from(Span::styled(breadcrumb, breadcrumb_style));
frame.render_widget(
Paragraph::new(breadcrumb_line),
Rect::new(area.x, area.y + 1, area.width, 1),
);
if let Some(ref desc) = result.item.description {
let desc_style = Style::default().fg(theme.line_number_fg);
let truncated_desc = if desc.len() > area.width as usize - 2 {
format!(" {}...", &desc[..area.width as usize - 5])
} else {
format!(" {}", desc)
};
frame.render_widget(
Paragraph::new(truncated_desc).style(desc_style),
Rect::new(area.x, area.y + 2, area.width, 1),
);
}
layout.add_search_result(result.page_index, result.item_index, area);
}
fn build_highlighted_text(
text: &str,
matches: &[usize],
normal_style: Style,
highlight_style: Style,
) -> Line<'static> {
if matches.is_empty() {
return Line::from(Span::styled(text.to_string(), normal_style));
}
let chars: Vec<char> = text.chars().collect();
let mut spans = Vec::new();
let mut current = String::new();
let mut in_highlight = false;
for (idx, ch) in chars.iter().enumerate() {
let should_highlight = matches.contains(&idx);
if should_highlight != in_highlight {
if !current.is_empty() {
let style = if in_highlight {
highlight_style
} else {
normal_style
};
spans.push(Span::styled(current, style));
current = String::new();
}
in_highlight = should_highlight;
}
current.push(*ch);
}
if !current.is_empty() {
let style = if in_highlight {
highlight_style
} else {
normal_style
};
spans.push(Span::styled(current, style));
}
Line::from(spans)
}
fn render_confirm_dialog(
frame: &mut Frame,
parent_area: Rect,
state: &SettingsState,
theme: &Theme,
) {
let changes = state.get_change_descriptions();
let dialog_width = 50.min(parent_area.width.saturating_sub(4));
let dialog_height = (7 + changes.len() as u16)
.min(20)
.min(parent_area.height.saturating_sub(4));
let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
frame.render_widget(Clear, dialog_area);
let title = format!(" {} ", t!("confirm.unsaved_changes_title"));
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.diagnostic_warning_fg))
.style(Style::default().bg(theme.popup_bg));
frame.render_widget(block, dialog_area);
let inner = Rect::new(
dialog_area.x + 2,
dialog_area.y + 1,
dialog_area.width.saturating_sub(4),
dialog_area.height.saturating_sub(2),
);
let mut y = inner.y;
let prompt = t!("confirm.unsaved_changes_prompt").to_string();
let prompt_style = Style::default().fg(theme.popup_text_fg);
frame.render_widget(
Paragraph::new(prompt).style(prompt_style),
Rect::new(inner.x, y, inner.width, 1),
);
y += 2;
let change_style = Style::default().fg(theme.popup_text_fg);
for change in changes
.iter()
.take((dialog_height as usize).saturating_sub(7))
{
let truncated = if change.len() > inner.width as usize - 2 {
format!("• {}...", &change[..inner.width as usize - 5])
} else {
format!("• {}", change)
};
frame.render_widget(
Paragraph::new(truncated).style(change_style),
Rect::new(inner.x, y, inner.width, 1),
);
y += 1;
}
let button_y = dialog_area.y + dialog_area.height - 3;
let sep_line: String = "─".repeat(inner.width as usize);
frame.render_widget(
Paragraph::new(sep_line).style(Style::default().fg(theme.split_separator_fg)),
Rect::new(inner.x, button_y - 1, inner.width, 1),
);
let options = [
t!("confirm.save_and_exit").to_string(),
t!("confirm.discard").to_string(),
t!("confirm.cancel").to_string(),
];
let total_width: u16 = options.iter().map(|o| o.len() as u16 + 4).sum::<u16>() + 4; let mut x = inner.x + (inner.width.saturating_sub(total_width)) / 2;
for (idx, label) in options.iter().enumerate() {
let is_selected = idx == state.confirm_dialog_selection;
let is_hovered = state.confirm_dialog_hover == Some(idx);
let button_width = label.len() as u16 + 4;
let style = if is_selected {
Style::default()
.fg(theme.menu_highlight_fg)
.bg(theme.menu_highlight_bg)
.add_modifier(ratatui::style::Modifier::BOLD)
} else if is_hovered {
Style::default()
.fg(theme.menu_hover_fg)
.bg(theme.menu_hover_bg)
} else {
Style::default().fg(theme.popup_text_fg)
};
let text = if is_selected {
format!(">[ {} ]", label)
} else {
format!(" [ {} ]", label)
};
frame.render_widget(
Paragraph::new(text).style(style),
Rect::new(x, button_y, button_width + 1, 1),
);
x += button_width + 3;
}
let help = "←/→/Tab: Select Enter: Confirm Esc: Cancel";
let help_style = Style::default().fg(theme.line_number_fg);
frame.render_widget(
Paragraph::new(help).style(help_style),
Rect::new(inner.x, button_y + 1, inner.width, 1),
);
}
fn render_entry_dialog(
frame: &mut Frame,
parent_area: Rect,
state: &mut SettingsState,
theme: &Theme,
) {
let Some(dialog) = state.entry_dialog_mut() else {
return;
};
let dialog_width = (parent_area.width * 85 / 100).clamp(50, 90);
let dialog_height = (parent_area.height * 90 / 100).max(15);
let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
frame.render_widget(Clear, dialog_area);
let title = format!(" {} ", dialog.title);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.popup_border_fg))
.style(Style::default().bg(theme.popup_bg));
frame.render_widget(block, dialog_area);
let inner = Rect::new(
dialog_area.x + 2,
dialog_area.y + 1,
dialog_area.width.saturating_sub(4),
dialog_area.height.saturating_sub(5), );
let max_label_width = (inner.width / 2).max(20);
let label_col_width = dialog
.items
.iter()
.map(|item| item.name.len() as u16 + 2) .filter(|&w| w <= max_label_width)
.max()
.unwrap_or(20)
.min(max_label_width);
let total_content_height = dialog.total_content_height();
let viewport_height = inner.height as usize;
dialog.viewport_height = viewport_height;
let scroll_offset = dialog.scroll_offset;
let needs_scroll = total_content_height > viewport_height;
let mut content_y: usize = 0;
let mut screen_y = inner.y;
let first_editable = dialog.first_editable_index;
let has_readonly_items = first_editable > 0;
let has_editable_items = first_editable < dialog.items.len();
let needs_separator = has_readonly_items && has_editable_items;
for (idx, item) in dialog.items.iter().enumerate() {
if needs_separator && idx == first_editable {
let separator_start = content_y;
let separator_end = content_y + 1;
if separator_end > scroll_offset && screen_y < inner.y + inner.height {
let skip_sep = if separator_start < scroll_offset {
1
} else {
0
};
if skip_sep == 0 {
let sep_style = Style::default().fg(theme.line_number_fg);
let separator_line = "─".repeat(inner.width.saturating_sub(2) as usize);
frame.render_widget(
Paragraph::new(separator_line).style(sep_style),
Rect::new(inner.x + 1, screen_y, inner.width.saturating_sub(2), 1),
);
screen_y += 1;
}
}
content_y = separator_end;
}
let control_height = item.control.control_height() as usize;
let item_start = content_y;
let item_end = content_y + control_height;
if item_end <= scroll_offset {
content_y = item_end;
continue;
}
if screen_y >= inner.y + inner.height {
break;
}
let skip_rows = if item_start < scroll_offset {
(scroll_offset - item_start) as u16
} else {
0
};
let visible_height = control_height.saturating_sub(skip_rows as usize);
let available_height = (inner.y + inner.height).saturating_sub(screen_y) as usize;
let render_height = visible_height.min(available_height);
if render_height == 0 {
content_y = item_end;
continue;
}
let is_readonly = item.read_only;
let is_focused = !is_readonly && !dialog.focus_on_buttons && dialog.selected_item == idx;
let is_hovered = !is_readonly && dialog.hover_item == Some(idx);
if is_focused || is_hovered {
let bg_style = if is_focused {
Style::default().bg(theme.settings_selected_bg)
} else {
Style::default().bg(theme.menu_hover_bg)
};
for row in 0..render_height as u16 {
let row_area = Rect::new(inner.x, screen_y + row, inner.width, 1);
frame.render_widget(Paragraph::new("").style(bg_style), row_area);
}
}
let focus_indicator_width: u16 = 3;
if is_focused && skip_rows == 0 {
let indicator_style = Style::default()
.fg(theme.settings_selected_fg)
.add_modifier(Modifier::BOLD);
frame.render_widget(
Paragraph::new(">").style(indicator_style),
Rect::new(inner.x, screen_y, 1, 1),
);
}
if item.modified && skip_rows == 0 {
let modified_style = Style::default().fg(theme.settings_selected_fg);
frame.render_widget(
Paragraph::new("●").style(modified_style),
Rect::new(inner.x + 1, screen_y, 1, 1),
);
}
let control_area = Rect::new(
inner.x + focus_indicator_width,
screen_y,
inner.width.saturating_sub(focus_indicator_width),
render_height as u16,
);
let _layout = render_control(
frame,
control_area,
&item.control,
&item.name,
skip_rows,
theme,
Some(label_col_width.saturating_sub(focus_indicator_width)),
item.read_only,
);
screen_y += render_height as u16;
content_y = item_end;
}
if needs_scroll {
use crate::view::ui::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
let scrollbar_x = dialog_area.x + dialog_area.width - 3;
let scrollbar_area = Rect::new(scrollbar_x, inner.y, 1, inner.height);
let scrollbar_state =
ScrollbarState::new(total_content_height, viewport_height, scroll_offset);
let scrollbar_colors = ScrollbarColors::from_theme(theme);
render_scrollbar(frame, scrollbar_area, &scrollbar_state, &scrollbar_colors);
}
let button_y = dialog_area.y + dialog_area.height - 2;
let buttons: Vec<&str> = if dialog.is_new || dialog.no_delete {
vec!["[ Save ]", "[ Cancel ]"]
} else {
vec!["[ Save ]", "[ Delete ]", "[ Cancel ]"]
};
let button_width: u16 = buttons.iter().map(|b: &&str| b.len() as u16 + 2).sum();
let button_x = dialog_area.x + (dialog_area.width.saturating_sub(button_width)) / 2;
let mut x = button_x;
for (idx, label) in buttons.iter().enumerate() {
let is_selected = dialog.focus_on_buttons && dialog.focused_button == idx;
let is_hovered = dialog.hover_button == Some(idx);
let is_delete = !dialog.is_new && !dialog.no_delete && idx == 1;
let style = if is_selected {
Style::default()
.fg(theme.menu_highlight_fg)
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else if is_hovered {
Style::default()
.fg(theme.menu_hover_fg)
.bg(theme.menu_hover_bg)
} else if is_delete {
Style::default().fg(theme.diagnostic_error_fg)
} else {
Style::default().fg(theme.editor_fg)
};
frame.render_widget(
Paragraph::new(*label).style(style),
Rect::new(x, button_y, label.len() as u16, 1),
);
x += label.len() as u16 + 2;
}
let is_editing_json = dialog.editing_text && dialog.is_editing_json();
let (has_invalid_json, is_json_control) = dialog
.current_item()
.map(|item| match &item.control {
SettingControl::Text(state) => (!state.is_valid(), false),
SettingControl::Json(state) => (!state.is_valid(), is_editing_json),
_ => (false, false),
})
.unwrap_or((false, false));
let help_area = Rect::new(
dialog_area.x + 2,
button_y + 1,
dialog_area.width.saturating_sub(4),
1,
);
if has_invalid_json && !is_json_control {
let warning = "⚠ Invalid JSON - fix before leaving field";
let warning_style = Style::default().fg(theme.diagnostic_warning_fg);
frame.render_widget(Paragraph::new(warning).style(warning_style), help_area);
} else if has_invalid_json && is_json_control {
let warning = "⚠ Invalid JSON";
let warning_style = Style::default().fg(theme.diagnostic_warning_fg);
frame.render_widget(Paragraph::new(warning).style(warning_style), help_area);
} else if is_json_control {
let help = "↑↓←→:Move Enter:Newline Tab/Esc:Exit";
let help_style = Style::default().fg(theme.line_number_fg);
frame.render_widget(Paragraph::new(help).style(help_style), help_area);
} else {
let help = "↑↓:Navigate Tab:Fields/Buttons Enter:Edit/Confirm Esc:Cancel";
let help_style = Style::default().fg(theme.line_number_fg);
frame.render_widget(Paragraph::new(help).style(help_style), help_area);
}
}
fn render_help_overlay(frame: &mut Frame, parent_area: Rect, theme: &Theme) {
let help_items = [
(
"Navigation",
vec![
("↑ / ↓", "Move up/down"),
("Tab", "Switch between categories and settings"),
("Enter", "Activate/toggle setting"),
],
),
(
"Search",
vec![
("/", "Start search"),
("Esc", "Cancel search"),
("↑ / ↓", "Navigate results"),
("Enter", "Jump to result"),
],
),
(
"Actions",
vec![
("Ctrl+S", "Save settings"),
("Esc", "Close settings"),
("?", "Toggle this help"),
],
),
];
let dialog_width = 50.min(parent_area.width.saturating_sub(4));
let dialog_height = 20.min(parent_area.height.saturating_sub(4));
let dialog_x = parent_area.x + (parent_area.width.saturating_sub(dialog_width)) / 2;
let dialog_y = parent_area.y + (parent_area.height.saturating_sub(dialog_height)) / 2;
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
frame.render_widget(Clear, dialog_area);
let block = Block::default()
.title(" Keyboard Shortcuts ")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.menu_highlight_fg))
.style(Style::default().bg(theme.popup_bg));
frame.render_widget(block, dialog_area);
let inner = Rect::new(
dialog_area.x + 2,
dialog_area.y + 1,
dialog_area.width.saturating_sub(4),
dialog_area.height.saturating_sub(2),
);
let mut y = inner.y;
for (section_name, bindings) in &help_items {
if y >= inner.y + inner.height.saturating_sub(1) {
break;
}
let header_style = Style::default()
.fg(theme.menu_active_fg)
.add_modifier(Modifier::BOLD);
frame.render_widget(
Paragraph::new(*section_name).style(header_style),
Rect::new(inner.x, y, inner.width, 1),
);
y += 1;
for (key, description) in bindings {
if y >= inner.y + inner.height.saturating_sub(1) {
break;
}
let key_style = Style::default()
.fg(theme.diagnostic_info_fg)
.add_modifier(Modifier::BOLD);
let desc_style = Style::default().fg(theme.popup_text_fg);
let line = Line::from(vec![
Span::styled(format!(" {:12}", key), key_style),
Span::styled(*description, desc_style),
]);
frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, inner.width, 1));
y += 1;
}
y += 1; }
let footer_y = dialog_area.y + dialog_area.height - 2;
let footer = "Press ? or Esc or Enter to close";
let footer_style = Style::default().fg(theme.line_number_fg);
let centered_x = inner.x + (inner.width.saturating_sub(footer.len() as u16)) / 2;
frame.render_widget(
Paragraph::new(footer).style(footer_style),
Rect::new(centered_x, footer_y, footer.len() as u16, 1),
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_control_layout_info() {
let toggle = ControlLayoutInfo::Toggle(Rect::new(0, 0, 10, 1));
assert!(matches!(toggle, ControlLayoutInfo::Toggle(_)));
let number = ControlLayoutInfo::Number {
decrement: Rect::new(0, 0, 3, 1),
increment: Rect::new(4, 0, 3, 1),
value: Rect::new(8, 0, 5, 1),
};
assert!(matches!(number, ControlLayoutInfo::Number { .. }));
}
}