use bombadil_core::model::{Config, PythonPin, VenvLocation};
use iced::widget::canvas;
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 git: Option<bombadil_core::git::GitStatus>,
}
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,
git: None,
})
.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 visible(
entries: &[Entry],
dirs: &[std::path::PathBuf],
filter: &str,
selected: Option<usize>,
) -> Vec<usize> {
entries
.iter()
.enumerate()
.filter(|(i, entry)| {
selected == Some(*i)
|| crate::deps::name_matches(&entry.label, filter)
|| dirs.get(*i).and_then(|d| d.file_name()).is_some_and(|name| {
crate::deps::name_matches(&name.to_string_lossy(), filter)
})
})
.map(|(i, _)| i)
.collect()
}
pub fn pin_note(row: &EnvironmentRow) -> Option<String> {
let (Some(version), PythonPin::Version(pinned)) = (&row.python_version, &row.pin) else {
return None;
};
(!interpreter::satisfies_pin(version, &row.pin))
.then(|| format!("pinned {pinned}: sync to apply"))
}
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>,
busy: Option<(uuid::Uuid, f32)>,
waiting: Option<&str>,
shown: &[usize],
) -> iced::Element<'a, Message> {
let mut list = iced::widget::column![].spacing(theme::SPACE_2);
for (i, entry) in entries.iter().enumerate() {
if !shown.contains(&i) {
continue;
}
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)
.style(theme::text_muted)
.into(),
None => iced::widget::text("no env")
.font(theme::FONT_PROSE)
.size(theme::DATA)
.style(theme::text_muted)
.into(),
};
let disclosure = iced::widget::button(
iced::widget::text(if entry.expanded {
"\u{25be}"
} else {
"\u{25b8}"
})
.size(theme::BODY)
.width(iced::Length::Fill)
.align_x(iced::alignment::Horizontal::Center),
)
.on_press(Message::ProjectExpandToggled(i))
.padding(0.0)
.width(theme::DISCLOSURE_WIDTH)
.style(theme::button_bare(theme::Swatch::Slate));
let marker = iced::widget::container(
iced::widget::Space::new()
.width(theme::SELECTED_MARKER)
.height(iced::Length::Fill),
)
.height(iced::Length::Fill)
.style(move |t| iced::widget::container::Style {
background: is_selected.then_some(iced::Background::Color(theme::palette(t).jacket)),
..iced::widget::container::Style::default()
});
let content = iced::widget::row![
marker,
iced::widget::column![
iced::widget::row![
match busy {
Some((id, angle)) if id == entry.id => iced::widget::container(
crate::icons::spinner(angle, 12.0, theme::Swatch::Slate)
)
.width(theme::GLYPH_COLUMN_WIDTH)
.into(),
_ => theme::tip(
iced::widget::text(glyph.glyph.to_string())
.size(theme::BODY)
.style(theme::tinted(glyph.swatch))
.width(theme::GLYPH_COLUMN_WIDTH),
theme::state_meaning(state(entry)),
iced::widget::tooltip::Position::Right,
),
},
iced::widget::text(entry.label.clone())
.font(label_font)
.size(theme::BODY),
]
.spacing(theme::SPACE_1),
iced::widget::row![
iced::widget::Space::new().width(theme::GLYPH_COLUMN_WIDTH - theme::SPACE_1),
version_line
]
.push(entry.git.as_ref().map(|git| {
let dirty = if git.is_clean() { "" } else { "*" };
iced::widget::text(format!("{}{dirty}", git.branch))
.font(theme::FONT_DATA)
.size(theme::DATA)
.style(theme::text_muted)
.wrapping(iced::widget::text::Wrapping::None)
}))
.spacing(theme::SPACE_2),
]
.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)
.height(theme::PROJECT_ROW_HEIGHT)
.clip(true)
.style(move |t, status| iced::widget::button::Style {
background: match (is_selected, status) {
(true, _) => Some(iced::Background::Color(theme::palette(t).ink)),
(false, iced::widget::button::Status::Hovered) => {
Some(iced::Background::Color(theme::palette(t).ink))
}
_ => None,
},
text_color: theme::palette(t).parchment,
border: iced::Border {
radius: theme::RADIUS.into(),
..iced::Border::default()
},
..iced::widget::button::Style::default()
});
let actions = row_actions(i, is_selected, waiting, !entry.environments.is_empty());
let row: iced::Element<'a, Message> = if is_selected {
iced::widget::stack![row, actions].into()
} else {
iced::widget::hover(row, actions)
};
let mut group = iced::widget::column![
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 {
group = group.push(environment_row(
i,
entry.id,
environment,
entry.requires_python.as_deref(),
));
}
group = group.push(add_environment_row(entry.id));
}
list = list.push(
iced::widget::container(group)
.padding(theme::SPACE_1)
.style(move |t| {
let p = theme::palette(t);
iced::widget::container::Style {
border: iced::Border {
color: if is_selected { p.jacket } else { p.edge },
width: theme::HAIRLINE,
radius: theme::RADIUS_SURFACE.into(),
},
..iced::widget::container::Style::default()
}
}),
);
}
list.into()
}
struct Guide {
last: bool,
}
impl canvas::Program<Message> for Guide {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced::Renderer,
theme: &iced::Theme,
bounds: iced::Rectangle,
_cursor: iced::mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
let x = (theme::DISCLOSURE_WIDTH / 2.0).round() + 0.5;
let mid = (bounds.height / 2.0).round() + 0.5;
let path = canvas::Path::new(|b| {
b.move_to(iced::Point::new(x, 0.0));
b.line_to(iced::Point::new(
x,
if self.last { mid } else { bounds.height },
));
b.move_to(iced::Point::new(x, mid));
b.line_to(iced::Point::new(bounds.width, mid));
});
frame.stroke(
&path,
canvas::Stroke::default()
.with_color(theme::palette(theme).slate)
.with_width(theme::HAIRLINE),
);
vec![frame.into_geometry()]
}
}
fn guide<'a>(last: bool) -> iced::Element<'a, Message> {
iced::widget::canvas(Guide { last })
.width(theme::DISCLOSURE_WIDTH + theme::SPACE_2)
.height(theme::TREE_ROW_HEIGHT)
.into()
}
pub fn rail<'a>(
entries: &[Entry],
selected: Option<usize>,
busy: Option<(uuid::Uuid, f32)>,
shown: &[usize],
) -> iced::Element<'a, Message> {
use crate::icons::{self, Icon};
use crate::shortcuts::{Shortcut, keys};
let mut column = iced::widget::column![icons::icon_button(
Icon::PanelLeft,
format!("Expand sidebar ({})", keys(Shortcut::ToggleSidebar)),
Some(Message::SidebarToggled),
)]
.spacing(theme::SPACE_1)
.align_x(iced::Alignment::Center);
for (i, entry) in entries.iter().enumerate() {
if !shown.contains(&i) {
continue;
}
let glyph = theme::state_glyph(state(entry));
let mark: iced::Element<'a, Message> = match busy {
Some((id, angle)) if id == entry.id => {
icons::spinner(angle, 12.0, theme::Swatch::Slate)
}
_ => iced::widget::text(glyph.glyph.to_string())
.size(theme::BODY)
.style(theme::tinted(glyph.swatch))
.into(),
};
column = column.push(theme::tip(
iced::widget::button(mark)
.on_press(Message::ProjectSelected(i))
.padding([theme::SPACE_1, theme::SPACE_2])
.style(theme::button_choice(selected == Some(i))),
entry.label.clone(),
iced::widget::tooltip::Position::Right,
));
}
iced::widget::scrollable(column)
.height(iced::Length::Fill)
.into()
}
fn row_actions<'a>(
i: usize,
selected: bool,
waiting: Option<&str>,
has_environment: bool,
) -> iced::Element<'a, Message> {
use crate::icons::{Icon, icon_button};
use crate::shortcuts::{Shortcut, hint};
let sync = match waiting {
None => icon_button(
Icon::Refresh,
hint("Sync", Shortcut::Sync, selected),
Some(Message::SyncRequested(i)),
),
Some(label) => icon_button(Icon::Refresh, format!("Wait for {label} to finish"), None),
};
let mut buttons = iced::widget::row![];
if has_environment {
buttons = buttons.push(sync).push(crate::icons::tile_button(
crate::icons::Tile::Terminal,
hint("Open a terminal", Shortcut::Terminal, selected),
Some(Message::OpenTerminalHereRequested(i)),
));
}
let pill = iced::widget::container(buttons.push(icon_button(
Icon::Ellipsis,
"More: open folder, recreate, remove",
Some(Message::ContextMenuOpened(i)),
)))
.style(|t| iced::widget::container::Style {
background: Some(iced::Background::Color(theme::palette(t).ink)),
border: iced::Border {
radius: theme::RADIUS.into(),
..iced::Border::default()
},
..iced::widget::container::Style::default()
});
iced::widget::container(pill)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.align_x(iced::alignment::Horizontal::Right)
.align_y(iced::alignment::Vertical::Top)
.into()
}
fn environment_row<'a>(
project: usize,
project_id: uuid::Uuid,
row: &EnvironmentRow,
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 marker = iced::widget::container(
iced::widget::Space::new()
.width(theme::SELECTED_MARKER)
.height(iced::Length::Fill),
)
.height(iced::Length::Fill)
.style(move |t| iced::widget::container::Style {
background: active.then_some(iced::Background::Color(theme::palette(t).jacket)),
..iced::widget::container::Style::default()
});
let content = iced::widget::row![
marker,
iced::widget::text(glyph.glyph.to_string())
.size(theme::BODY)
.style(theme::tinted(glyph.swatch))
.width(theme::GLYPH_COLUMN_WIDTH),
iced::widget::column![
iced::widget::row![
iced::widget::text(name)
.font(theme::FONT_DATA)
.size(theme::DATA)
]
.push(active.then(|| theme::badge("default")))
.spacing(theme::SPACE_2)
.align_y(iced::Alignment::Center),
match pin_note(row) {
Some(note) => iced::widget::text(format!("{version} \u{b7} {note}"))
.font(theme::FONT_DATA)
.size(theme::LABEL)
.style(theme::text_warning),
None => iced::widget::text(version)
.font(theme::FONT_DATA)
.size(theme::LABEL)
.style(theme::text_muted),
},
]
.spacing(0),
]
.spacing(theme::SPACE_1)
.height(iced::Length::Fill)
.align_y(iced::Alignment::Center);
let location = row.location.clone();
let mut pressable = iced::widget::row![
guide(false),
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 |t, status| iced::widget::button::Style {
background: match (active, status) {
(true, _) => Some(iced::Background::Color(theme::palette(t).ink)),
(false, iced::widget::button::Status::Hovered) => {
Some(iced::Background::Color(theme::palette(t).ink))
}
_ => None,
},
text_color: theme::palette(t).parchment,
border: iced::Border {
radius: theme::RADIUS.into(),
..iced::Border::default()
},
..iced::widget::button::Style::default()
}),
]
.spacing(0)
.height(theme::TREE_ROW_HEIGHT)
.align_y(iced::Alignment::Center);
pressable = pressable.push(crate::icons::icon_button(
crate::icons::Icon::Trash,
"Remove environment",
Some(Message::EnvironmentRemoveRequested(
project_id,
location.clone(),
)),
));
iced::widget::mouse_area(pressable)
.on_right_press(Message::EnvironmentRemoveRequested(project_id, location))
.into()
}
fn add_environment_row<'a>(project_id: uuid::Uuid) -> iced::Element<'a, Message> {
iced::widget::row![
guide(true),
iced::widget::button(
iced::widget::text("+ add environment")
.size(theme::DATA)
.style(theme::text_muted)
.height(iced::Length::Fill)
.align_y(iced::alignment::Vertical::Center),
)
.on_press(Message::EnvironmentAdded(project_id))
.width(iced::Length::Fill)
.height(theme::TREE_ROW_HEIGHT)
.padding(theme::SPACE_1)
.style(theme::button_bare(theme::Swatch::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,
}
}
#[test]
fn a_pin_the_environment_misses_says_sync_applies_it() {
assert_eq!(
pin_note(&pinned("3.11.9", "3.12").environments[0]).as_deref(),
Some("pinned 3.12: sync to apply")
);
assert_eq!(pin_note(&pinned("3.12.4", "3.12").environments[0]), None);
assert_eq!(
pin_note(&entry(None, None).environments[0]),
None,
"nothing probed, nothing to claim"
);
assert_eq!(
pin_note(&entry(Some("3.11.9"), None).environments[0]),
None,
"unpinned"
);
}
fn named(label: &str) -> Entry {
Entry {
label: label.into(),
..entry(None, None)
}
}
#[test]
fn the_project_filter_matches_label_or_folder_and_keeps_the_selection() {
let entries = [named("api"), named("worker"), named("web")];
let dirs = [
std::path::PathBuf::from("/code/api"),
std::path::PathBuf::from("/code/jobs"),
std::path::PathBuf::from("/code/site"),
];
assert_eq!(visible(&entries, &dirs, "", None), [0, 1, 2]);
assert_eq!(visible(&entries, &dirs, "WO", None), [1]);
assert_eq!(visible(&entries, &dirs, "jobs", None), [1], "by folder too");
assert_eq!(
visible(&entries, &dirs, "code", None),
Vec::<usize>::new(),
"a parent every project shares narrows nothing, so it does not match"
);
assert_eq!(
visible(&entries, &dirs, "web", Some(0)),
[0, 2],
"the selected project never vanishes"
);
}
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,
git: None,
}
}
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()
}
}
}