use crate::app::Message;
use crate::theme;
use crate::{env_editor, index_editor, settings_editor};
use bombadil_core::model::{Config, PythonPin, TerminalChoice, UvSource, VenvLocation};
use bombadil_core::terminal::Os;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
Global,
Project(Uuid),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Section {
#[default]
Environments,
Python,
Uv,
Terminal,
Scripts,
EnvVars,
Indexes,
}
impl Section {
pub const ALL: [Section; 7] = [
Section::Environments,
Section::Python,
Section::Uv,
Section::Terminal,
Section::Scripts,
Section::EnvVars,
Section::Indexes,
];
pub fn menu_label(self) -> &'static str {
match self {
Section::Environments => "Virtual environments",
Section::Python => "Python version",
Section::Uv => "uv",
Section::Terminal => "Terminal",
Section::Scripts => "Scripts",
Section::EnvVars => "Environment variables",
Section::Indexes => "Package indexes and auth",
}
}
pub fn label(self) -> &'static str {
match self {
Section::Environments => "Environments",
Section::Python => "Python",
Section::Uv => "uv",
Section::Terminal => "Terminal",
Section::Scripts => "Scripts",
Section::EnvVars => "Env vars",
Section::Indexes => "Indexes",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct State {
pub scope: Scope,
pub section: Section,
}
impl Default for State {
fn default() -> Self {
Self {
scope: Scope::Global,
section: Section::ALL[0],
}
}
}
pub fn resolve_scope(scope: Scope, config: &Config) -> Scope {
match scope {
Scope::Global => Scope::Global,
Scope::Project(id) if config.projects.iter().any(|project| project.id == id) => scope,
Scope::Project(_) => Scope::Global,
}
}
pub fn pin_from_input(input: &str) -> PythonPin {
let trimmed = input.trim();
if trimmed.is_empty() {
PythonPin::Unpinned
} else {
PythonPin::Version(trimmed.to_string())
}
}
pub fn inherited_label(section: Section, config: &Config, os: Os) -> String {
match section {
Section::Python => match &config.settings.python {
PythonPin::Unpinned => "no pin -- uv chooses".to_string(),
PythonPin::Version(version) => version.clone(),
},
Section::Uv => match &config.settings.uv_source {
UvSource::Auto => "auto".to_string(),
UvSource::Bundled => "bundled".to_string(),
UvSource::FromPath => "from PATH".to_string(),
UvSource::Custom { path } => path.display().to_string(),
},
Section::Terminal => match config.terminal_defaults.get(&os) {
Some(TerminalChoice::Detected(kind)) => {
crate::context_menu::terminal_name(*kind).to_string()
}
Some(TerminalChoice::Custom { .. }) => "a custom command".to_string(),
None => "none set".to_string(),
},
Section::Environments | Section::Scripts | Section::EnvVars | Section::Indexes => {
String::new()
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn view<'a>(
section: Section,
scope: Scope,
config: &Config,
env_drafts: &env_editor::ValueDrafts,
credential_drafts: &[String],
uv_custom_path_draft: &str,
uv_custom_validation: Option<&Result<String, String>>,
) -> iced::Element<'a, Message> {
let selected_project = match scope {
Scope::Global => None,
Scope::Project(id) => config.projects.iter().position(|p| p.id == id),
};
let mut scopes = iced::widget::row![scope_button("Global", Scope::Global, scope)]
.spacing(theme::SPACE_1)
.align_y(iced::Alignment::Center);
for project in &config.projects {
scopes = scopes.push(scope_button(
project.label.clone(),
Scope::Project(project.id),
scope,
));
}
let mut sections = iced::widget::column![].spacing(0);
for entry in Section::ALL {
let selected = entry == section;
sections = sections.push(
iced::widget::button(iced::widget::text(entry.label()).size(theme::BODY))
.on_press_maybe((!selected).then_some(Message::PreferencesSectionSelected(entry)))
.width(iced::Length::Fill)
.padding([theme::SPACE_1, theme::SPACE_2])
.style(theme::button_bare(if selected {
theme::PARCHMENT
} else {
theme::SLATE
})),
);
}
let panel: iced::Element<'a, Message> = match (section, selected_project) {
(Section::EnvVars, None) => env_editor::global_view(config, env_drafts),
(Section::EnvVars, Some(i)) => env_editor::project_view(config, i, env_drafts),
(Section::Indexes, None) => index_editor::global_view(config, credential_drafts),
(Section::Indexes, Some(i)) => index_editor::project_view(config, i),
(Section::Environments, Some(i)) => environments_panel(config, i),
(Section::Environments, None) => settings_editor::global_view(
&config.settings,
&config.projects,
uv_custom_path_draft,
uv_custom_validation,
),
(Section::Uv, None) => settings_editor::global_view(
&config.settings,
&config.projects,
uv_custom_path_draft,
uv_custom_validation,
),
(Section::Uv, Some(i)) => uv_panel(config, scope, i),
(Section::Scripts, _) => settings_scripts_panel(config, selected_project),
(Section::Python, _) => python_panel(config, scope, selected_project),
(Section::Terminal, _) => terminal_panel(config, scope, selected_project),
};
let card = iced::widget::container(
iced::widget::column![
iced::widget::row![
iced::widget::text("Preferences")
.font(theme::FONT_PROSE_SEMIBOLD)
.size(theme::DISPLAY),
iced::widget::Space::new().width(iced::Length::Fill),
iced::widget::button(iced::widget::text("Close").size(theme::BODY))
.on_press(Message::PreferencesClosed)
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_quiet),
]
.align_y(iced::Alignment::Center),
iced::widget::scrollable(scopes).width(iced::Length::Fill),
theme::hairline_row(),
iced::widget::row![
iced::widget::container(sections).width(theme::PREFS_SECTION_WIDTH),
theme::hairline_column(),
iced::widget::container(iced::widget::scrollable(panel).height(iced::Length::Fill))
.width(iced::Length::Fill)
.padding([0.0, theme::SPACE_3]),
]
.height(theme::PREFS_BODY_HEIGHT)
.spacing(theme::SPACE_2),
]
.spacing(theme::SPACE_3),
)
.width(theme::PREFS_WIDTH)
.padding(theme::SPACE_4)
.style(theme::panel);
let positioner = iced::widget::container(iced::widget::center(iced::widget::opaque(card)))
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.style(|_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(theme::SCRIM)),
..iced::widget::container::Style::default()
});
iced::widget::opaque(iced::widget::mouse_area(positioner).on_press(Message::PreferencesClosed))
}
fn environments_panel<'a>(config: &Config, project_index: usize) -> iced::Element<'a, Message> {
let Some(project) = config.projects.get(project_index) else {
return iced::widget::text("").into();
};
let id = project.id;
let removable = project.environments.len() > 1;
let mut column = iced::widget::column![
iced::widget::text(
"Every virtual environment this project has. Sync, Open terminal and Recreate \
act on the default one, and selecting the project shows it; the sidebar lists \
them all."
)
.size(theme::BODY)
.color(theme::SLATE),
]
.spacing(theme::SPACE_3);
for environment in &project.environments {
let location = environment.location.clone();
let active = environment.location == project.active;
let resolved = bombadil_core::venv_path::resolve(project, environment, &config.settings)
.map(|path| path.display().to_string())
.unwrap_or_else(|err| err.to_string());
let pin = match &environment.python {
PythonPin::Version(version) => version.clone(),
PythonPin::Unpinned => String::new(),
};
let mut card = iced::widget::column![
iced::widget::row![
iced::widget::button(
iced::widget::text(if active {
"default for this project"
} else {
"make default"
})
.size(theme::BODY)
)
.on_press_maybe((!active).then_some(Message::EnvironmentActivated(
project_index,
location.clone()
)))
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_choice(active)),
iced::widget::Space::new().width(iced::Length::Fill),
iced::widget::button(iced::widget::text("Remove").size(theme::BODY))
.on_press_maybe(
removable
.then_some(Message::EnvironmentRemoveRequested(id, location.clone()))
)
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_quiet),
]
.align_y(iced::Alignment::Center),
theme::labeled_value("path", resolved),
]
.spacing(theme::SPACE_2);
let place = |label: &'static str, to: VenvLocation, current: bool| {
let from = location.clone();
iced::widget::button(iced::widget::text(label).size(theme::BODY))
.on_press(Message::EnvironmentLocationSelected(id, from, to))
.padding([theme::SPACE_1, theme::SPACE_2])
.style(theme::button_choice(current))
};
let for_picker = location.clone();
card = card.push(
iced::widget::row![
iced::widget::text("where")
.size(theme::LABEL)
.color(theme::SLATE)
.width(theme::INDEX_LABEL_WIDTH),
place(
"follow global location",
VenvLocation::Default,
environment.location == VenvLocation::Default
),
place(
"alongside",
VenvLocation::Alongside,
environment.location == VenvLocation::Alongside
),
iced::widget::button(iced::widget::text("folder...").size(theme::BODY))
.on_press(Message::EnvironmentPickFolderRequested(id, for_picker))
.padding([theme::SPACE_1, theme::SPACE_2])
.style(theme::button_choice(matches!(
environment.location,
VenvLocation::Custom { .. }
))),
]
.spacing(theme::SPACE_1)
.align_y(iced::Alignment::Center),
);
let for_input = location.clone();
card = card.push(
iced::widget::row![
iced::widget::text("python")
.size(theme::LABEL)
.color(theme::SLATE)
.width(theme::INDEX_LABEL_WIDTH),
iced::widget::text_input("3.12, or empty to let uv choose", &pin)
.on_input(move |version| Message::EnvironmentPinChanged(
id,
for_input.clone(),
version
))
.font(theme::FONT_DATA)
.size(theme::DATA)
.padding(theme::SPACE_1),
]
.spacing(theme::SPACE_2)
.align_y(iced::Alignment::Center),
);
column = column.push(
iced::widget::container(card)
.padding(theme::SPACE_3)
.style(theme::panel),
);
}
column
.push(
iced::widget::button(iced::widget::text("Add environment").size(theme::BODY))
.on_press(Message::EnvironmentAdded(id))
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_quiet),
)
.push(
iced::widget::text(
"Changing an environment's python deletes and rebuilds it on the next Sync, \
which asks first. Removing one here leaves the directory on disk.",
)
.size(theme::LABEL)
.color(theme::SLATE),
)
.into()
}
fn settings_scripts_panel<'a>(
config: &Config,
project_index: Option<usize>,
) -> iced::Element<'a, Message> {
if project_index.is_some() {
return iced::widget::column![
iced::widget::text(
"A project's own pre-activate scripts live in its Scripts tab, next to \
Dependencies and Members -- they are that project's behaviour rather than \
a setting. Switch scope to Global to edit the scripts every project runs."
)
.size(theme::BODY)
.color(theme::SLATE),
]
.into();
}
crate::scripts_editor::global_view(config)
}
fn python_panel<'a>(
config: &Config,
scope: Scope,
project_index: Option<usize>,
) -> iced::Element<'a, Message> {
let project = project_index.and_then(|i| config.projects.get(i));
let inheriting = false;
let effective = match project.and_then(|p| p.active_environment()) {
Some(environment) => environment.python.clone(),
None => config.settings.python.clone(),
};
let current = match &effective {
PythonPin::Version(version) => version.clone(),
PythonPin::Unpinned => String::new(),
};
let mut column = iced::widget::column![
iced::widget::text(
"The interpreter Sync builds this environment with. Leave it empty to let uv \
choose, subject to the manifest's requires-python."
)
.size(theme::BODY)
.color(theme::SLATE),
]
.spacing(theme::SPACE_3);
if project.is_some() {
column = column.push(inherit_row(Section::Python, config, scope, inheriting));
}
column = column.push(
iced::widget::text_input("3.12, or 3.12.13", ¤t)
.on_input(move |version| Message::PreferencesPythonPinChanged(scope, version))
.font(theme::FONT_DATA)
.size(theme::DATA)
.padding(theme::SPACE_2),
);
column
.push(
iced::widget::text(
"Changing this deletes the existing environment and rebuilds it. Anything \
installed in it that is not in the lock file is lost. Sync asks first.",
)
.size(theme::LABEL)
.color(theme::SLATE),
)
.into()
}
fn uv_panel<'a>(config: &Config, scope: Scope, project_index: usize) -> iced::Element<'a, Message> {
let Some(project) = config.projects.get(project_index) else {
return iced::widget::text("").into();
};
let inheriting = project.uv_source == bombadil_core::model::Override::Inherit;
let effective = project
.uv_source
.resolve(&config.settings.uv_source)
.clone();
let mut column = iced::widget::column![
iced::widget::text("Which uv runs this project's commands.")
.size(theme::BODY)
.color(theme::SLATE),
inherit_row(Section::Uv, config, scope, inheriting),
]
.spacing(theme::SPACE_2);
for (label, source) in [
("auto", UvSource::Auto),
("bundled", UvSource::Bundled),
("from PATH", UvSource::FromPath),
] {
let selected = !inheriting && effective == source;
column = column.push(
iced::widget::button(iced::widget::text(label).size(theme::BODY))
.on_press(Message::PreferencesUvSourceOverridden(scope, source))
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_choice(selected)),
);
}
column
.push(
iced::widget::text(
"A specific uv binary is configured globally, in this section's Global \
scope, because it is probed for its version before it is trusted.",
)
.size(theme::LABEL)
.color(theme::SLATE),
)
.into()
}
fn terminal_panel<'a>(
config: &Config,
scope: Scope,
project_index: Option<usize>,
) -> iced::Element<'a, Message> {
let os = Os::host();
let project = project_index.and_then(|i| config.projects.get(i));
let inheriting = project.is_some_and(|p| !p.terminals.contains_key(&os));
let current = match project {
Some(project) => project
.terminals
.get(&os)
.or_else(|| config.terminal_defaults.get(&os)),
None => config.terminal_defaults.get(&os),
}
.cloned();
let mut column = iced::widget::column![
iced::widget::text(
"Which terminal the Open terminal action launches. Set per operating system, \
because the terminals that exist differ between them."
)
.size(theme::BODY)
.color(theme::SLATE),
]
.spacing(theme::SPACE_2);
if project.is_some() {
column = column.push(inherit_row(Section::Terminal, config, scope, inheriting));
}
for kind in bombadil_core::terminal::TerminalKind::for_os(os) {
let selected = current == Some(TerminalChoice::Detected(kind));
let warning = crate::context_menu::activation_warning(kind);
let mut label = iced::widget::column![
iced::widget::text(crate::context_menu::terminal_name(kind)).size(theme::BODY),
];
if !warning.is_empty() {
label = label.push(
iced::widget::text(warning.trim())
.size(theme::LABEL)
.color(theme::SLATE),
);
}
column = column.push(
iced::widget::button(label)
.on_press(Message::PreferencesTerminalOverridden(
scope,
TerminalChoice::Detected(kind),
))
.width(iced::Length::Fill)
.padding([theme::SPACE_1, theme::SPACE_2])
.style(theme::button_choice(selected)),
);
}
column
.push(
iced::widget::text(
"Listed for this machine's OS. A terminal that is not installed reports so \
when opened rather than being hidden here.",
)
.size(theme::LABEL)
.color(theme::SLATE),
)
.into()
}
fn inherit_row<'a>(
section: Section,
config: &Config,
scope: Scope,
inheriting: bool,
) -> iced::Element<'a, Message> {
let inherited = inherited_label(section, config, Os::host());
iced::widget::row![
iced::widget::checkbox(inheriting)
.label("Inherit from global")
.text_size(theme::BODY)
.on_toggle_maybe(
(!inheriting)
.then_some(move |_| Message::PreferencesInheritToggled(scope, section))
),
iced::widget::text(format!("({inherited})"))
.size(theme::DATA)
.color(theme::SLATE),
]
.spacing(theme::SPACE_2)
.align_y(iced::Alignment::Center)
.into()
}
fn scope_button<'a>(
label: impl Into<String>,
scope: Scope,
current: Scope,
) -> iced::Element<'a, Message> {
let selected = scope == current;
iced::widget::button(iced::widget::text(label.into()).size(theme::BODY))
.on_press_maybe((!selected).then_some(Message::PreferencesScopeSelected(scope)))
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_choice(selected))
.into()
}
#[cfg(test)]
mod tests {
use super::*;
use bombadil_core::model::Project;
fn config_with(projects: Vec<Project>) -> Config {
Config {
projects,
..Config::default()
}
}
#[test]
fn preferences_open_on_global_and_the_first_section() {
let state = State::default();
assert_eq!(state.scope, Scope::Global);
assert_eq!(state.section, Section::ALL[0]);
}
#[test]
fn a_project_scope_survives_the_list_being_reordered() {
let first = Project {
id: Uuid::from_u128(1),
label: "api".into(),
..Project::default()
};
let second = Project {
id: Uuid::from_u128(2),
label: "web".into(),
..Project::default()
};
let scope = Scope::Project(second.id);
let config = config_with(vec![second.clone(), first.clone()]);
assert_eq!(resolve_scope(scope, &config), scope);
let reordered = config_with(vec![first, second]);
assert_eq!(resolve_scope(scope, &reordered), scope);
}
#[test]
fn a_scope_whose_project_is_gone_falls_back_to_global() {
let scope = Scope::Project(Uuid::from_u128(7));
assert_eq!(resolve_scope(scope, &config_with(vec![])), Scope::Global);
}
#[test]
fn global_always_resolves_to_itself() {
assert_eq!(
resolve_scope(Scope::Global, &config_with(vec![])),
Scope::Global
);
}
#[test]
fn every_section_has_a_distinct_label() {
let labels: Vec<&str> = Section::ALL.iter().map(|section| section.label()).collect();
for (i, label) in labels.iter().enumerate() {
assert!(
!label.trim().is_empty(),
"{:?} has no label",
Section::ALL[i]
);
assert!(
!labels[i + 1..].contains(label),
"two sections are both called {label}"
);
}
}
}