use bombadil_core::model::{Config, PythonPin, VenvLocation};
use std::path::PathBuf;
use crate::app::Message;
use crate::interpreter;
use crate::theme::{self, State};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvironmentRow {
pub location: VenvLocation,
pub path: PathBuf,
pub python_version: Option<String>,
pub pin: PythonPin,
pub active: bool,
}
pub struct Entry {
pub id: uuid::Uuid,
pub label: String,
pub requires_python: Option<String>,
pub environments: Vec<EnvironmentRow>,
pub expanded: bool,
}
pub fn entries(config: &Config) -> Vec<Entry> {
config
.projects
.iter()
.map(|project| Entry {
id: project.id,
label: project.label.clone(),
requires_python: None,
environments: project
.environments
.iter()
.map(|environment| EnvironmentRow {
location: environment.location.clone(),
path: bombadil_core::venv_path::resolve(project, environment, &config.settings)
.unwrap_or_default(),
python_version: None,
pin: environment.python.clone(),
active: environment.location == project.active,
})
.collect(),
expanded: project.expanded,
})
.collect()
}
pub fn rolled_up(rows: &[EnvironmentRow], requires_python: Option<&str>) -> State {
if rows.is_empty() {
return State::Absent;
}
let mut any_absent = false;
for row in rows {
match row_state(row, requires_python) {
State::Drifted => return State::Drifted,
State::Absent => any_absent = true,
State::Present => {}
}
}
if any_absent {
State::Absent
} else {
State::Present
}
}
pub fn environment_press(project: usize, row: &EnvironmentRow) -> Message {
Message::EnvironmentActivated(project, row.location.clone())
}
pub fn row_state(row: &EnvironmentRow, requires_python: Option<&str>) -> State {
match &row.python_version {
None => State::Absent,
Some(version) => {
let agrees = interpreter::version_satisfies(version, requires_python)
&& interpreter::satisfies_pin(version, &row.pin);
if agrees {
State::Present
} else {
State::Drifted
}
}
}
}
pub fn state(entry: &Entry) -> State {
rolled_up(&entry.environments, entry.requires_python.as_deref())
}
pub fn view<'a>(entries: &[Entry], selected: Option<usize>) -> iced::Element<'a, Message> {
let mut list = iced::widget::column![].spacing(theme::SPACE_2);
for (i, entry) in entries.iter().enumerate() {
let is_selected = selected == Some(i);
let glyph = theme::state_glyph(state(entry));
let label_font = if is_selected {
theme::FONT_PROSE_SEMIBOLD
} else {
theme::FONT_PROSE
};
let version_line: iced::Element<'_, Message> = match entry
.environments
.iter()
.find(|row| row.active)
.and_then(|row| row.python_version.as_ref())
{
Some(version) => iced::widget::text(version.clone())
.font(theme::FONT_DATA)
.size(theme::DATA)
.color(theme::SLATE)
.into(),
None => iced::widget::text("no env")
.font(theme::FONT_PROSE)
.size(theme::DATA)
.color(theme::SLATE)
.into(),
};
let disclosure = iced::widget::button(
iced::widget::text(if entry.expanded {
"\u{25be}"
} else {
"\u{25b8}"
})
.size(theme::BODY),
)
.on_press(Message::ProjectExpandToggled(i))
.padding(0.0)
.width(theme::DISCLOSURE_WIDTH)
.style(theme::button_bare(theme::SLATE));
let marker = iced::widget::container(
iced::widget::Space::new()
.width(theme::SELECTED_MARKER)
.height(iced::Length::Fill),
)
.height(iced::Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: is_selected.then_some(iced::Background::Color(theme::JACKET)),
..iced::widget::container::Style::default()
});
let content = iced::widget::row![
marker,
iced::widget::column![
iced::widget::row![
iced::widget::text(glyph.glyph.to_string())
.size(theme::BODY)
.color(glyph.colour)
.width(theme::GLYPH_COLUMN_WIDTH),
iced::widget::text(entry.label.clone())
.font(label_font)
.size(theme::BODY),
]
.spacing(theme::SPACE_1),
version_line,
]
.spacing(theme::SPACE_1),
]
.spacing(theme::SPACE_2);
let row = iced::widget::button(content)
.on_press(Message::ProjectSelected(i))
.padding(theme::SPACE_1)
.width(iced::Length::Fill)
.style(move |_theme, status| iced::widget::button::Style {
background: match (is_selected, status) {
(true, _) => Some(iced::Background::Color(theme::INK)),
(false, iced::widget::button::Status::Hovered) => {
Some(iced::Background::Color(theme::INK))
}
_ => None,
},
text_color: theme::PARCHMENT,
border: iced::Border {
radius: theme::RADIUS.into(),
..iced::Border::default()
},
..iced::widget::button::Style::default()
});
list = list.push(
iced::widget::mouse_area(
iced::widget::row![disclosure, row]
.spacing(theme::SPACE_1)
.align_y(iced::Alignment::Center),
)
.on_right_press(Message::ContextMenuOpened(i)),
);
if entry.expanded {
for environment in &entry.environments {
list = list.push(environment_row(
i,
entry.id,
environment,
entry.environments.len() > 1,
entry.requires_python.as_deref(),
));
}
list = list.push(add_environment_row(entry.id));
}
}
list.into()
}
fn environment_row<'a>(
project: usize,
project_id: uuid::Uuid,
row: &EnvironmentRow,
removable: bool,
requires_python: Option<&str>,
) -> iced::Element<'a, Message> {
let glyph = theme::state_glyph(row_state(row, requires_python));
let name = row
.path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| row.path.display().to_string());
let version = match &row.python_version {
Some(version) => version.clone(),
None => "not created".to_string(),
};
let active = row.active;
let suffix = if active { " default" } else { "" };
let marker = iced::widget::container(
iced::widget::Space::new()
.width(theme::SELECTED_MARKER)
.height(iced::Length::Fill),
)
.height(iced::Length::Fill)
.style(move |_theme| iced::widget::container::Style {
background: active.then_some(iced::Background::Color(theme::JACKET)),
..iced::widget::container::Style::default()
});
let content = iced::widget::row![
marker,
iced::widget::text(glyph.glyph.to_string())
.size(theme::BODY)
.color(glyph.colour)
.width(theme::GLYPH_COLUMN_WIDTH),
iced::widget::column![
iced::widget::text(format!("{name}{suffix}"))
.font(theme::FONT_DATA)
.size(theme::DATA),
iced::widget::text(version)
.font(theme::FONT_DATA)
.size(theme::LABEL)
.color(theme::SLATE),
]
.spacing(0),
]
.spacing(theme::SPACE_1)
.height(iced::Length::Fill)
.align_y(iced::Alignment::Center);
let location = row.location.clone();
let pressable = iced::widget::row![
iced::widget::Space::new()
.width(theme::DISCLOSURE_WIDTH + theme::SPACE_2)
.height(theme::TREE_ROW_HEIGHT),
iced::widget::button(content)
.on_press(environment_press(project, row))
.width(iced::Length::Fill)
.height(theme::TREE_ROW_HEIGHT)
.padding(theme::SPACE_1)
.style(move |_theme, status| iced::widget::button::Style {
background: match (active, status) {
(true, _) => Some(iced::Background::Color(theme::INK)),
(false, iced::widget::button::Status::Hovered) => {
Some(iced::Background::Color(theme::INK))
}
_ => None,
},
text_color: theme::PARCHMENT,
border: iced::Border {
radius: theme::RADIUS.into(),
..iced::Border::default()
},
..iced::widget::button::Style::default()
}),
]
.spacing(0)
.height(theme::TREE_ROW_HEIGHT);
if removable {
iced::widget::mouse_area(pressable)
.on_right_press(Message::EnvironmentRemoveRequested(project_id, location))
.into()
} else {
pressable.into()
}
}
fn add_environment_row<'a>(project_id: uuid::Uuid) -> iced::Element<'a, Message> {
iced::widget::row![
iced::widget::Space::new()
.width(theme::DISCLOSURE_WIDTH + theme::SPACE_2)
.height(theme::TREE_ROW_HEIGHT),
iced::widget::button(
iced::widget::text("+ add environment")
.size(theme::DATA)
.color(theme::SLATE),
)
.on_press(Message::EnvironmentAdded(project_id))
.width(iced::Length::Fill)
.height(theme::TREE_ROW_HEIGHT)
.padding(theme::SPACE_1)
.style(theme::button_bare(theme::SLATE)),
]
.spacing(0)
.height(theme::TREE_ROW_HEIGHT)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
fn row(path: &str) -> EnvironmentRow {
EnvironmentRow {
location: VenvLocation::Custom {
path: PathBuf::from(path),
},
path: PathBuf::from(path),
python_version: None,
pin: PythonPin::Unpinned,
active: false,
}
}
fn entry(python_version: Option<&str>, requires_python: Option<&str>) -> Entry {
Entry {
id: uuid::Uuid::nil(),
label: "api".into(),
requires_python: requires_python.map(String::from),
environments: vec![EnvironmentRow {
python_version: python_version.map(String::from),
active: true,
..row("/p/api/.venv")
}],
expanded: true,
}
}
fn pinned(python_version: &str, pin: &str) -> Entry {
Entry {
environments: vec![EnvironmentRow {
python_version: Some(python_version.into()),
pin: PythonPin::Version(pin.into()),
active: true,
..row("/p/api/.venv")
}],
..entry(Some(python_version), None)
}
}
#[test]
fn every_environment_row_is_pressable_including_the_default_one() {
let default_row = EnvironmentRow {
active: true,
..row("/p/api/.venv")
};
let other = EnvironmentRow {
active: false,
..row("/p/api/.venv-311")
};
for row in [&default_row, &other] {
let message = environment_press(0, row);
assert!(
matches!(message, Message::EnvironmentActivated(0, ref location)
if *location == row.location),
"every row must send its own activation; got {message:?}"
);
}
}
#[test]
fn the_projects_glyph_rolls_up_its_environments() {
let present = EnvironmentRow {
python_version: Some("3.12".into()),
pin: PythonPin::Version("3.12".into()),
..row("/p/api/.venv")
};
let absent = EnvironmentRow {
python_version: None,
..row("/p/api/.venv-311")
};
let drifted = EnvironmentRow {
python_version: Some("3.11".into()),
pin: PythonPin::Version("3.12".into()),
..row("/p/api/.venv-x")
};
assert_eq!(
rolled_up(std::slice::from_ref(&present), None),
State::Present
);
assert_eq!(
rolled_up(&[present.clone(), absent.clone()], None),
State::Absent
);
assert_eq!(
rolled_up(&[present, absent, drifted], None),
State::Drifted,
"drift outranks absent: an environment that disagrees with its pin \
is the one that needs the user"
);
}
#[test]
fn a_project_with_no_environments_reads_as_absent() {
assert_eq!(rolled_up(&[], None), State::Absent);
}
#[test]
fn each_environment_row_is_judged_against_its_own_pin() {
let ok = EnvironmentRow {
python_version: Some("3.11".into()),
pin: PythonPin::Version("3.11".into()),
..row("/p/api/.venv-311")
};
let also_ok = EnvironmentRow {
python_version: Some("3.12".into()),
pin: PythonPin::Version("3.12".into()),
..row("/p/api/.venv-312")
};
assert_eq!(row_state(&ok, None), State::Present);
assert_eq!(row_state(&also_ok, None), State::Present);
assert_eq!(rolled_up(&[ok, also_ok], None), State::Present);
}
#[test]
fn a_venv_off_its_pin_reads_as_drift() {
assert_eq!(state(&pinned("3.11.9", "3.12")), State::Drifted);
}
#[test]
fn a_venv_on_its_pin_is_present_even_when_it_records_fewer_segments() {
assert_eq!(state(&pinned("3.12", "3.12.13")), State::Present);
}
#[test]
fn an_unpinned_project_still_reports_drift_against_requires_python() {
assert_eq!(state(&entry(Some("3.9.1"), Some(">=3.11"))), State::Drifted);
}
#[test]
fn a_venv_on_its_pin_but_off_requires_python_still_drifts() {
let entry = Entry {
environments: vec![EnvironmentRow {
python_version: Some("3.9.1".into()),
pin: PythonPin::Version("3.9".into()),
active: true,
..row("/p/api/.venv")
}],
..entry(Some("3.9.1"), Some(">=3.11"))
};
assert_eq!(state(&entry), State::Drifted);
}
#[test]
fn every_entry_starts_hollow_and_still_lists_the_project() {
let entries = entries(&config_with(&["api"]));
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].requires_python, None);
assert!(
entries[0]
.environments
.iter()
.all(|row| row.python_version.is_none()),
"no environment may claim a version before its probe lands"
);
}
#[test]
fn entries_follow_config_order_so_the_list_does_not_reshuffle() {
let entries = entries(&config_with(&["web", "api"]));
let labels: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect();
assert_eq!(labels, vec!["web", "api"]);
}
#[test]
fn no_venv_is_absent() {
assert_eq!(state(&entry(None, Some(">=3.11"))), State::Absent);
}
#[test]
fn a_venv_satisfying_requires_python_is_present() {
assert_eq!(
state(&entry(Some("3.12.4"), Some(">=3.11"))),
State::Present
);
}
#[test]
fn a_venv_older_than_requires_python_is_drifted() {
assert_eq!(
state(&entry(Some("3.9.18"), Some(">=3.11"))),
State::Drifted
);
}
#[test]
fn no_requirement_is_present_regardless_of_the_venv_version() {
assert_eq!(state(&entry(Some("3.9.18"), None)), State::Present);
}
#[test]
fn an_unparseable_requirement_falls_back_to_present_not_drifted() {
assert_eq!(state(&entry(Some("3.9.18"), Some("<3.5"))), State::Present);
assert_eq!(
state(&entry(Some("3.9.18"), Some("not a specifier"))),
State::Present
);
}
fn config_with(labels: &[&str]) -> bombadil_core::model::Config {
bombadil_core::model::Config {
projects: labels.iter().map(|label| project(label)).collect(),
..bombadil_core::model::Config::default()
}
}
fn project(label: &str) -> bombadil_core::model::Project {
bombadil_core::model::Project {
id: uuid::Uuid::new_v4(),
label: label.into(),
pyproject_path: std::path::PathBuf::from(format!("/p/{label}/pyproject.toml")),
environments: vec![bombadil_core::model::Environment {
location: bombadil_core::model::VenvLocation::Alongside,
python: bombadil_core::model::PythonPin::Unpinned,
}],
active: bombadil_core::model::VenvLocation::Alongside,
..bombadil_core::model::Project::default()
}
}
}