use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use toml::Value as TomlValue;
use toml::value::Table as TomlTable;
use super::palette::PaletteCompletionContext;
use super::render::{centered_rect, truncate_with_ellipsis};
use super::*;
pub(super) struct ConfigFileUpdate {
pub(super) rendered_value: String,
pub(super) runtime: RuntimeConfig,
}
impl AppState {
pub(super) fn open_config_editor(&mut self, key_hint: Option<&str>) {
self.palette.open = false;
self.palette.input.clear();
self.palette.clear_completion();
self.palette.clear_local_result();
self.config_editor.open = true;
self.config_editor.mode = ConfigEditorMode::Browse;
self.config_editor.input.clear();
if let Some(key) = key_hint {
if let Some(index) = config_editor_field_index(key) {
self.config_editor.selected_field = index;
} else {
self.status = format!(
"config editor does not support {key}; opened {}",
self.selected_config_editor_field().key
);
return;
}
}
self.status = format!(
"config editor opened: {}",
self.selected_config_editor_field().key
);
}
pub(super) fn close_config_editor(&mut self) {
self.config_editor.open = false;
self.config_editor.mode = ConfigEditorMode::Browse;
self.config_editor.input.clear();
self.status = "config editor closed".to_string();
}
pub(super) fn selected_config_editor_field(&self) -> &'static ConfigEditorField {
let index = self
.config_editor
.selected_field
.min(CONFIG_EDITOR_FIELDS.len().saturating_sub(1));
&CONFIG_EDITOR_FIELDS[index]
}
pub(super) fn move_config_editor_up(&mut self) {
if self.config_editor.selected_field > 0 {
self.config_editor.selected_field -= 1;
}
}
pub(super) fn move_config_editor_down(&mut self) {
if self.config_editor.selected_field + 1 < CONFIG_EDITOR_FIELDS.len() {
self.config_editor.selected_field += 1;
}
}
fn config_editor_seed_input(&self, key: &str) -> String {
match read_config_key_from_file(&self.runtime.config_path, key) {
Ok(Some(value)) => render_toml_value(&value),
Ok(None) => config_value_suggestions(self, Some(&key.to_string()))
.into_iter()
.next()
.map(|suggestion| suggestion.value)
.unwrap_or_default(),
Err(_) => String::new(),
}
}
pub(super) fn start_config_editor_edit(&mut self) {
let key = self.selected_config_editor_field().key;
self.config_editor.mode = ConfigEditorMode::Edit;
self.config_editor.input = self.config_editor_seed_input(key);
self.status = format!("editing config {key}");
}
pub(super) fn cycle_config_editor_value(&mut self) {
let key = self.selected_config_editor_field().key;
let suggestions = config_value_suggestions(self, Some(&key.to_string()));
if suggestions.is_empty() {
self.status = format!("no preset values for {key}");
return;
}
let current = if matches!(self.config_editor.mode, ConfigEditorMode::Edit) {
self.config_editor.input.trim().to_string()
} else {
self.config_editor_seed_input(key)
};
let next_index = suggestions
.iter()
.position(|suggestion| suggestion.value == current)
.map(|index| (index + 1) % suggestions.len())
.unwrap_or(0);
let next_value = suggestions[next_index].value.clone();
if matches!(self.config_editor.mode, ConfigEditorMode::Edit) {
self.config_editor.input = next_value;
self.status = format!("preset value selected for {key}");
return;
}
tracing::info!(
op = "config.set",
status = "started",
key = %key,
value_literal = %next_value,
source = "config_editor_cycle"
);
match update_config_key_in_file(&self.runtime.config_path, key, &next_value) {
Ok(update) => {
apply_runtime_update(self, update.runtime);
self.status = format!("config updated: {key} = {}", update.rendered_value);
tracing::info!(
op = "config.set",
status = "succeeded",
key = %key,
value = %update.rendered_value,
config = %self.runtime.config_path.display(),
source = "config_editor_cycle"
);
}
Err(error) => {
tracing::error!(
op = "config.set",
status = "failed",
key = %key,
error = %error,
source = "config_editor_cycle"
);
self.status = format!("failed to set config key {key}: {error}");
}
}
}
pub(super) fn save_config_editor_value(&mut self) {
let key = self.selected_config_editor_field().key;
let value_literal = self.config_editor.input.trim().to_string();
if value_literal.is_empty() {
self.status = format!("empty value for {key}; press x to unset instead");
return;
}
tracing::info!(
op = "config.set",
status = "started",
key = %key,
value_literal = %value_literal,
source = "config_editor"
);
match update_config_key_in_file(&self.runtime.config_path, key, &value_literal) {
Ok(update) => {
apply_runtime_update(self, update.runtime);
self.config_editor.mode = ConfigEditorMode::Browse;
self.config_editor.input.clear();
self.status = format!("config updated: {key} = {}", update.rendered_value);
tracing::info!(
op = "config.set",
status = "succeeded",
key = %key,
value = %update.rendered_value,
config = %self.runtime.config_path.display(),
source = "config_editor"
);
}
Err(error) => {
tracing::error!(
op = "config.set",
status = "failed",
key = %key,
error = %error,
source = "config_editor"
);
self.status = format!("failed to set config key {key}: {error}");
}
}
}
pub(super) fn unset_selected_config_key(&mut self) {
let key = self.selected_config_editor_field().key;
tracing::info!(op = "config.unset", status = "started", key = %key);
match remove_config_key_from_file(&self.runtime.config_path, key) {
Ok(Some(runtime)) => {
apply_runtime_update(self, runtime);
self.config_editor.mode = ConfigEditorMode::Browse;
self.config_editor.input.clear();
self.status = format!("config key unset: {key}");
tracing::info!(
op = "config.unset",
status = "succeeded",
key = %key,
config = %self.runtime.config_path.display()
);
}
Ok(None) => {
self.status = format!("config key already unset: {key}");
}
Err(error) => {
tracing::error!(op = "config.unset", status = "failed", key = %key, error = %error);
self.status = format!("failed to unset config key {key}: {error}");
}
}
}
}
pub(super) fn run_palette_config(state: &mut AppState, command: &str) {
tracing::debug!(command = %command, "user executed config command from palette");
let mut segments = command.split_whitespace();
let _ = segments.next();
let action = segments.next().unwrap_or("show").to_ascii_lowercase();
match action.as_str() {
"show" => {
if let Some(key) = segments.next() {
show_config_key(state, key);
} else {
state.open_config_editor(None);
}
}
"edit" => {
state.open_config_editor(segments.next());
}
"get" => {
let Some(key) = segments.next() else {
state.status = "usage: config get <key>".to_string();
return;
};
show_config_key(state, key);
}
"set" => {
let Some(key) = segments.next() else {
state.status = "usage: config set <key> <value>".to_string();
return;
};
let value_literal = segments.collect::<Vec<_>>().join(" ");
if value_literal.trim().is_empty() {
state.status = "usage: config set <key> <value>".to_string();
return;
}
tracing::info!(
op = "config.set",
status = "started",
key = %key,
value_literal = %value_literal
);
match update_config_key_in_file(&state.runtime.config_path, key, &value_literal) {
Ok(update) => {
apply_runtime_update(state, update.runtime);
state.status = format!("config updated: {key} = {}", update.rendered_value);
tracing::info!(
op = "config.set",
status = "succeeded",
key = %key,
value = %update.rendered_value,
config = %state.runtime.config_path.display()
);
}
Err(error) => {
tracing::error!(
op = "config.set",
status = "failed",
key = %key,
error = %error
);
state.status = format!("failed to set config key {key}: {error}");
}
}
}
"help" => {
state.status =
"config usage: show [key] | edit [key] | get <key> | set <key> <value>".to_string();
}
_ => {
state.status =
"config usage: show [key] | edit [key] | get <key> | set <key> <value>".to_string();
}
}
}
fn show_config_key(state: &mut AppState, key: &str) {
if key.trim().is_empty() {
state.status = "usage: config get <key>".to_string();
return;
}
let file_value = read_config_key_from_file(&state.runtime.config_path, key);
match file_value {
Ok(Some(value)) => {
state.status = format!("config file {key} = {}", render_toml_value(&value));
}
Ok(None) => {
if let Some(value) = effective_config_value(state, key) {
state.status = format!("config effective {key} = {value} (default/runtime)");
} else {
state.status = format!("config key not found: {key}");
}
}
Err(error) => {
tracing::error!(key = %key, error = %error, "failed to read config key");
state.status = format!("failed to read config key {key}: {error}");
}
}
}
fn read_config_key_from_file(
config_path: &Path,
key: &str,
) -> std::result::Result<Option<TomlValue>, String> {
let table = read_config_table(config_path)?;
Ok(lookup_config_key(&table, key).cloned())
}
pub(super) fn update_config_key_in_file(
config_path: &Path,
key: &str,
value_literal: &str,
) -> std::result::Result<ConfigFileUpdate, String> {
let mut table = read_config_table(config_path)?;
let value = parse_toml_value_literal(value_literal);
set_config_key(&mut table, key, value)?;
let rendered = lookup_config_key(&table, key)
.map(render_toml_value)
.unwrap_or_else(|| "<unknown>".to_string());
let content = render_config_table(&table)?;
let runtime = validate_updated_config(config_path, &content)?;
write_config_content(config_path, &content)?;
Ok(ConfigFileUpdate {
rendered_value: rendered,
runtime,
})
}
pub(super) fn remove_config_key_from_file(
config_path: &Path,
key: &str,
) -> std::result::Result<Option<RuntimeConfig>, String> {
let mut table = read_config_table(config_path)?;
let removed = remove_config_key(&mut table, key)?;
if !removed {
return Ok(None);
}
let content = render_config_table(&table)?;
let runtime = validate_updated_config(config_path, &content)?;
write_config_content(config_path, &content)?;
Ok(Some(runtime))
}
fn read_config_table(config_path: &Path) -> std::result::Result<TomlTable, String> {
let content = match fs::read_to_string(config_path) {
Ok(content) => content,
Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(),
Err(error) => {
return Err(format!("failed to read {}: {error}", config_path.display()));
}
};
if content.trim().is_empty() {
return Ok(TomlTable::new());
}
toml::from_str::<TomlTable>(&content)
.map_err(|error| format!("failed to parse TOML in {}: {error}", config_path.display()))
}
fn render_config_table(table: &TomlTable) -> std::result::Result<String, String> {
let mut content = toml::to_string_pretty(table)
.map_err(|error| format!("failed to serialize config table: {error}"))?;
if !content.ends_with('\n') {
content.push('\n');
}
Ok(content)
}
fn write_config_content(config_path: &Path, content: &str) -> std::result::Result<(), String> {
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"failed to create config directory {}: {error}",
parent.display()
)
})?;
}
fs::write(config_path, content).map_err(|error| {
format!(
"failed to write config file {}: {error}",
config_path.display()
)
})
}
fn validate_updated_config(
config_path: &Path,
content: &str,
) -> std::result::Result<RuntimeConfig, String> {
crate::infra::config::load_from_document(config_path, content)
.map_err(|error| error.to_string())
}
fn lookup_config_key<'a>(table: &'a TomlTable, key: &str) -> Option<&'a TomlValue> {
let mut key_parts = key.split('.').filter(|part| !part.is_empty());
let first = key_parts.next()?;
let mut current = table.get(first)?;
for segment in key_parts {
current = current.as_table()?.get(segment)?;
}
Some(current)
}
fn set_config_key(
table: &mut TomlTable,
key: &str,
value: TomlValue,
) -> std::result::Result<(), String> {
let mut key_parts: Vec<&str> = key.split('.').filter(|part| !part.is_empty()).collect();
if key_parts.is_empty() {
return Err("empty key".to_string());
}
let Some(leaf) = key_parts.pop() else {
return Err("empty key".to_string());
};
let mut current = table;
for segment in key_parts {
let node = current
.entry(segment.to_string())
.or_insert_with(|| TomlValue::Table(TomlTable::new()));
if !node.is_table() {
return Err(format!(
"cannot set {key}: key segment {segment} already holds a value"
));
}
current = node
.as_table_mut()
.ok_or_else(|| format!("cannot set {key}: key segment {segment} is not a table"))?;
}
current.insert(leaf.to_string(), value);
Ok(())
}
fn remove_config_key(table: &mut TomlTable, key: &str) -> std::result::Result<bool, String> {
let key_parts: Vec<&str> = key.split('.').filter(|part| !part.is_empty()).collect();
if key_parts.is_empty() {
return Err("empty key".to_string());
}
Ok(remove_config_key_segments(table, &key_parts))
}
fn remove_config_key_segments(table: &mut TomlTable, segments: &[&str]) -> bool {
if segments.len() == 1 {
return table.remove(segments[0]).is_some();
}
let key = segments[0];
let (removed, prune_child) = if let Some(child) = table.get_mut(key) {
if let Some(child_table) = child.as_table_mut() {
let removed = remove_config_key_segments(child_table, &segments[1..]);
(removed, removed && child_table.is_empty())
} else {
(false, false)
}
} else {
(false, false)
};
if prune_child {
table.remove(key);
}
removed
}
fn parse_toml_value_literal(value_literal: &str) -> TomlValue {
let literal = value_literal.trim();
if literal.is_empty() {
return TomlValue::String(String::new());
}
let snippet = format!("value = {literal}");
if let Ok(parsed) = toml::from_str::<TomlTable>(&snippet)
&& let Some(value) = parsed.get("value")
{
return value.clone();
}
TomlValue::String(literal.to_string())
}
fn render_toml_value(value: &TomlValue) -> String {
value.to_string()
}
fn config_editor_field_index(key: &str) -> Option<usize> {
CONFIG_EDITOR_FIELDS
.iter()
.position(|field| field.key.eq_ignore_ascii_case(key))
}
pub(super) fn apply_runtime_update(state: &mut AppState, runtime: RuntimeConfig) {
let old_inbox_auto_sync_interval_secs = state.runtime.inbox_auto_sync_interval_secs;
let selected_path_hint = state.selected_kernel_tree_path();
let enabled_mailboxes: HashSet<String> = state.enabled_mailboxes().into_iter().collect();
let active_mailbox = state.active_thread_mailbox.clone();
state.runtime = runtime;
state.rebuild_main_page_keymap();
state.ui_state_path = ui_state::path_for_data_dir(&state.runtime.data_dir);
state.subscriptions = default_subscriptions(
&state.runtime,
&enabled_mailboxes,
Some(active_mailbox.as_str()),
if state.runtime.imap.is_complete() && !state.imap_defaults_initialized {
MyInboxDefault::EnableOnFirstOpen
} else {
MyInboxDefault::PreservePersistedChoice
},
);
if state.runtime.imap.is_complete() {
state.imap_defaults_initialized = true;
}
if let Some(index) = state
.subscriptions
.iter()
.position(|item| same_mailbox_name(&item.mailbox, &state.active_thread_mailbox))
{
state.subscription_index = index;
state.sync_subscription_row_to_selected_item();
}
state.reconcile_inbox_auto_sync();
state.reconcile_subscription_auto_sync();
if state.runtime.inbox_auto_sync_interval_secs != old_inbox_auto_sync_interval_secs {
state.defer_inbox_auto_sync();
state.defer_subscription_auto_sync();
}
state.refresh_kernel_tree_rows(selected_path_hint.as_deref());
if matches!(state.ui_page, UiPage::CodeBrowser) && !state.supports_code_browser() {
state.ui_page = UiPage::Mail;
state.code_focus = CodePaneFocus::Tree;
}
}
fn effective_config_value(state: &AppState, key: &str) -> Option<String> {
match key {
"config.path" => Some(state.runtime.config_path.display().to_string()),
"storage.data_dir" => Some(state.runtime.data_dir.display().to_string()),
"storage.database" => Some(state.runtime.database_path.display().to_string()),
"storage.raw_mail_dir" => Some(state.runtime.raw_mail_dir.display().to_string()),
"storage.patch_dir" => Some(state.runtime.patch_dir.display().to_string()),
"logging.dir" => Some(state.runtime.log_dir.display().to_string()),
"logging.filter" => Some(state.runtime.log_filter.clone()),
"b4.path" => Some(
state
.runtime
.b4_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<none>".to_string()),
),
"source.mailbox" | "imap.mailbox" => Some(state.runtime.source_mailbox.clone()),
"imap.email" => state.runtime.imap.email.clone(),
"imap.user" => state.runtime.imap.user.clone(),
"imap.pass" => state.runtime.imap.pass.clone(),
"imap.server" => state.runtime.imap.server.clone(),
"imap.serverport" => state
.runtime
.imap
.server_port
.map(|value| value.to_string()),
"imap.encryption" => state
.runtime
.imap
.encryption
.map(|value| value.as_str().to_string()),
"imap.proxy" => state.runtime.imap.proxy.clone(),
"source.lore_base_url" => Some(state.runtime.lore_base_url.clone()),
"ui.startup_sync" => Some(state.runtime.startup_sync.to_string()),
"ui.keymap" => Some(state.runtime.ui_keymap.as_str().to_string()),
"ui.keymap_base" => Some(state.runtime.ui_keymap_base.as_str().to_string()),
"ui.inbox_auto_sync_interval_secs" => {
Some(state.runtime.inbox_auto_sync_interval_secs.to_string())
}
"kernel.trees" => Some(format!(
"[{}]",
state
.runtime
.kernel_trees
.iter()
.map(|path| format!("\"{}\"", path.display()))
.collect::<Vec<_>>()
.join(", ")
)),
"kernel.tree" => state
.runtime
.kernel_trees
.first()
.map(|path| path.display().to_string()),
_ => None,
}
}
pub(super) fn config_completion_suggestions(
state: &AppState,
context: &PaletteCompletionContext,
) -> Vec<PaletteSuggestion> {
let action = context
.tokens
.get(1)
.map(|value| value.to_ascii_lowercase())
.unwrap_or_default();
match context.active_index {
1 => vec![
PaletteSuggestion {
value: "edit".to_string(),
description: Some("Open visual config editor".to_string()),
},
PaletteSuggestion {
value: "get".to_string(),
description: Some("Read one config key".to_string()),
},
PaletteSuggestion {
value: "help".to_string(),
description: Some("Show config command usage".to_string()),
},
PaletteSuggestion {
value: "set".to_string(),
description: Some("Write one config key".to_string()),
},
PaletteSuggestion {
value: "show".to_string(),
description: Some("Show config file path or one key".to_string()),
},
],
2 => {
let owned_editor_keys: Vec<&str>;
let keys = if action == "set" {
CONFIG_SET_KEYS
} else if action == "edit" {
owned_editor_keys = CONFIG_EDITOR_FIELDS.iter().map(|field| field.key).collect();
&owned_editor_keys
} else {
CONFIG_GET_KEYS
};
keys.iter()
.map(|key| PaletteSuggestion {
value: (*key).to_string(),
description: Some("Config key".to_string()),
})
.collect()
}
3 if action == "set" => config_value_suggestions(state, context.tokens.get(2)),
_ => Vec::new(),
}
}
fn config_value_suggestions(state: &AppState, key: Option<&String>) -> Vec<PaletteSuggestion> {
let Some(key) = key.map(String::as_str) else {
return Vec::new();
};
match key {
"logging.filter" => ["trace", "debug", "info", "warn", "error"]
.iter()
.map(|value| PaletteSuggestion {
value: (*value).to_string(),
description: Some("Log filter".to_string()),
})
.collect(),
"source.mailbox" | "imap.mailbox" => state
.subscriptions
.iter()
.map(|subscription| PaletteSuggestion {
value: subscription.mailbox.clone(),
description: Some("Mailbox".to_string()),
})
.collect(),
"imap.email" => vec![PaletteSuggestion {
value: state
.runtime
.imap
.email
.as_ref()
.map(|value| format!("\"{value}\""))
.unwrap_or_else(|| "\"you@example.com\"".to_string()),
description: Some("Self email; also default IMAP login".to_string()),
}],
"imap.user" => vec![PaletteSuggestion {
value: state
.runtime
.imap
.user
.as_ref()
.map(|value| format!("\"{value}\""))
.or_else(|| {
state
.runtime
.imap
.email
.as_ref()
.map(|value| format!("\"{value}\""))
})
.unwrap_or_else(|| "\"you@example.com\"".to_string()),
description: Some("IMAP login account; Gmail usually needs full email".to_string()),
}],
"imap.pass" => vec![PaletteSuggestion {
value: "\"imap-pass\"".to_string(),
description: Some("IMAP login password".to_string()),
}],
"imap.server" => vec![PaletteSuggestion {
value: state
.runtime
.imap
.server
.as_ref()
.map(|value| format!("\"{value}\""))
.unwrap_or_else(|| "\"imap.example.com\"".to_string()),
description: Some("IMAP server host".to_string()),
}],
"imap.serverport" => vec![PaletteSuggestion {
value: state
.runtime
.imap
.server_port
.map(|value| value.to_string())
.unwrap_or_else(|| "993".to_string()),
description: Some("IMAP server port".to_string()),
}],
"imap.encryption" => ["tls", "ssl", "starttls", "none"]
.iter()
.map(|value| PaletteSuggestion {
value: format!("\"{value}\""),
description: Some("IMAP encryption".to_string()),
})
.collect(),
"imap.proxy" => [
"socks5://127.0.0.1:7890",
"socks5://10.0.2.2:7890",
"http://127.0.0.1:7890",
]
.iter()
.map(|value| PaletteSuggestion {
value: format!("\"{value}\""),
description: Some("IMAP proxy URL".to_string()),
})
.collect(),
"source.lore_base_url" => vec![PaletteSuggestion {
value: "https://lore.kernel.org".to_string(),
description: Some("Lore base URL".to_string()),
}],
"kernel.trees" => vec![PaletteSuggestion {
value: "[\"/path/to/linux\"]".to_string(),
description: Some("TOML array".to_string()),
}],
"kernel.tree" => state
.runtime
.kernel_trees
.first()
.map(|path| {
vec![PaletteSuggestion {
value: format!("\"{}\"", path.display()),
description: Some("Current kernel tree".to_string()),
}]
})
.unwrap_or_default(),
"storage.data_dir" => vec![PaletteSuggestion {
value: format!("\"{}\"", state.runtime.data_dir.display()),
description: Some("Current data dir".to_string()),
}],
"storage.database" => vec![PaletteSuggestion {
value: format!("\"{}\"", state.runtime.database_path.display()),
description: Some("Current database path".to_string()),
}],
"storage.raw_mail_dir" => vec![PaletteSuggestion {
value: format!("\"{}\"", state.runtime.raw_mail_dir.display()),
description: Some("Current raw mail dir".to_string()),
}],
"storage.patch_dir" => vec![PaletteSuggestion {
value: format!("\"{}\"", state.runtime.patch_dir.display()),
description: Some("Current patch dir".to_string()),
}],
"logging.dir" => vec![PaletteSuggestion {
value: format!("\"{}\"", state.runtime.log_dir.display()),
description: Some("Current log dir".to_string()),
}],
"ui.startup_sync" => ["true", "false"]
.iter()
.map(|value| PaletteSuggestion {
value: (*value).to_string(),
description: Some("Auto-sync on TUI startup".to_string()),
})
.collect(),
"ui.keymap" => [
("default", "j/l focus, i/k move"),
("vim", "h/l focus, j/k move, gg/G jump, qq quit"),
("custom", "ui.keymap_base plus ui.custom_keymap overrides"),
]
.iter()
.map(|(value, description)| PaletteSuggestion {
value: (*value).to_string(),
description: Some((*description).to_string()),
})
.collect(),
"ui.keymap_base" => [
("default", "j/l focus, i/k move"),
("vim", "h/l focus, j/k move, gg/G jump, qq quit"),
]
.iter()
.map(|(value, description)| PaletteSuggestion {
value: (*value).to_string(),
description: Some((*description).to_string()),
})
.collect(),
"ui.inbox_auto_sync_interval_secs" => ["15", "30", "60", "300"]
.iter()
.map(|value| PaletteSuggestion {
value: (*value).to_string(),
description: Some("Seconds between My Inbox background sync runs".to_string()),
})
.collect(),
"b4.path" => vec![PaletteSuggestion {
value: "\"/usr/bin/b4\"".to_string(),
description: Some("Path to b4 executable".to_string()),
}],
_ => Vec::new(),
}
}
pub(super) fn draw_config_editor(frame: &mut Frame<'_>, state: &AppState) {
let area = centered_rect(88, 76, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title("Runtime Config")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::LightGreen));
let inner = block.inner(area);
frame.render_widget(block, area);
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(10),
Constraint::Length(
if matches!(state.config_editor.mode, ConfigEditorMode::Edit) {
3
} else {
2
},
),
])
.split(inner);
let header = format!(
"file: {} | :config opens this editor | changes are written back immediately",
state.runtime.config_path.display()
);
frame.render_widget(Paragraph::new(header), sections[0]);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(46), Constraint::Percentage(54)])
.split(sections[1]);
let file_table = read_config_table(&state.runtime.config_path).ok();
let selected_index = state
.config_editor
.selected_field
.min(CONFIG_EDITOR_FIELDS.len().saturating_sub(1));
let mut list_state = ListState::default();
list_state.select(Some(selected_index));
let list_width = body[0].width.saturating_sub(4) as usize;
let items: Vec<ListItem> = CONFIG_EDITOR_FIELDS
.iter()
.map(|field| {
let file_value = file_table
.as_ref()
.and_then(|table| lookup_config_key(table, field.key))
.map(render_toml_value);
let effective_value = effective_config_value(state, field.key);
let (value, source) = if let Some(value) = file_value {
(value, "file")
} else if let Some(value) = effective_value {
(value, "default")
} else {
("<unset>".to_string(), "unset")
};
let line = format!("{} = {} [{}]", field.key, value, source);
ListItem::new(truncate_with_ellipsis(&line, list_width))
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title("Fields")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Gray)),
)
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, body[0], &mut list_state);
let field = state.selected_config_editor_field();
let file_value = file_table
.as_ref()
.and_then(|table| lookup_config_key(table, field.key))
.map(render_toml_value)
.unwrap_or_else(|| "<unset>".to_string());
let effective_value =
effective_config_value(state, field.key).unwrap_or_else(|| "<unset>".to_string());
let suggestions = config_value_suggestions(state, Some(&field.key.to_string()));
let mut details = vec![
format!("Key: {}", field.key),
format!("About: {}", field.description),
String::new(),
format!("File value: {}", file_value),
format!("Effective: {}", effective_value),
format!(
"Source: {}",
if file_value == "<unset>" {
"runtime default / derived fallback"
} else {
"explicit value from config file"
}
),
];
if !suggestions.is_empty() {
details.push(String::new());
details.push("Presets:".to_string());
for suggestion in suggestions.iter().take(6) {
match suggestion.description.as_deref() {
Some(description) if !description.is_empty() => {
details.push(format!(" {} ({description})", suggestion.value));
}
_ => details.push(format!(" {}", suggestion.value)),
}
}
}
details.push(String::new());
details.push("Unset removes the explicit key and falls back to runtime defaults.".to_string());
let detail = Paragraph::new(details.join("\n"))
.block(
Block::default()
.title("Selected Field")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Gray)),
)
.wrap(Wrap { trim: false });
frame.render_widget(detail, body[1]);
if matches!(state.config_editor.mode, ConfigEditorMode::Edit) {
let footer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(sections[2]);
frame.render_widget(
Paragraph::new(format!("Editing {} as TOML literal", field.key)),
footer[0],
);
let prompt = format!("> {}", state.config_editor.input);
frame.render_widget(Paragraph::new(prompt), footer[1]);
frame.render_widget(
Paragraph::new(
"Enter save | Esc cancel | Tab cycle presets | strings may be bare or quoted",
),
footer[2],
);
if footer[1].width > 0 {
let cursor_col =
(2 + state.config_editor.input.chars().count()).min(footer[1].width as usize - 1);
frame.set_cursor_position((footer[1].x + cursor_col as u16, footer[1].y));
}
} else {
let footer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(sections[2]);
frame.render_widget(
Paragraph::new("i/k or ↑/↓ move | Enter/e edit | Tab presets | x unset | Esc close"),
footer[0],
);
frame.render_widget(
Paragraph::new(
"Use TOML literals for arrays/paths when needed, e.g. [\"/path/to/linux\"].",
),
footer[1],
);
}
}