use crate::app::Message;
use crate::icons::{self, Icon};
use crate::shortcuts::{self, Shortcut};
use crate::theme;
use bombadil_core::model::Project;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VenvStatus {
Present,
Absent,
}
pub struct Header {
pub label: String,
pub path: PathBuf,
pub venv_status: VenvStatus,
pub python_version: Option<String>,
pub venv_path: Option<PathBuf>,
pub git: Option<bombadil_core::git::GitStatus>,
}
pub fn header(
project: &Project,
venv_path: Option<PathBuf>,
venv_probe: &dyn Fn(&Project) -> Option<String>,
) -> Header {
let python_version = venv_probe(project);
let venv_status = if python_version.is_some() {
VenvStatus::Present
} else {
VenvStatus::Absent
};
Header {
label: project.label.clone(),
path: project_dir(project),
venv_status,
python_version,
venv_path,
git: None,
}
}
pub fn git_summary(git: &bombadil_core::git::GitStatus) -> String {
let mut parts = vec![match (&git.upstream, git.detached) {
(_, true) => format!("detached at {}", git.branch),
(Some(upstream), false)
if upstream
.split_once('/')
.is_some_and(|(_, name)| name == git.branch) =>
{
git.branch.clone()
}
(Some(upstream), false) => format!("{} \u{2192} {upstream}", git.branch),
(None, false) => format!("{} (no upstream)", git.branch),
}];
let ahead_behind: Vec<String> = [("\u{2191}", git.ahead), ("\u{2193}", git.behind)]
.into_iter()
.filter(|(_, n)| *n > 0)
.map(|(arrow, n)| format!("{arrow}{n}"))
.collect();
if !ahead_behind.is_empty() {
parts.push(ahead_behind.join(" "));
}
let changes: Vec<String> = [
(git.staged, "staged"),
(git.modified, "modified"),
(git.untracked, "untracked"),
(git.conflicted, "conflicted"),
]
.into_iter()
.filter(|(n, _)| *n > 0)
.map(|(n, kind)| format!("{n} {kind}"))
.collect();
parts.push(if changes.is_empty() {
"clean".to_string()
} else {
changes.join(", ")
});
parts.join(" \u{b7} ")
}
pub struct Tile {
pub label: &'static str,
pub state: theme::State,
pub value: String,
pub press: Option<Message>,
pub sync: Option<Message>,
}
pub fn env_value(
status: VenvStatus,
python: Option<&String>,
state: theme::State,
requires_python: Option<&str>,
) -> String {
match (status, python) {
(VenvStatus::Absent, _) | (VenvStatus::Present, None) => "no environment".to_string(),
(VenvStatus::Present, Some(version)) => match (state, requires_python) {
(theme::State::Drifted, Some(req)) => {
format!("Python {version} does not satisfy {req}")
}
_ => format!("Python {version}"),
},
}
}
pub fn worst(states: impl IntoIterator<Item = theme::State>) -> theme::State {
states.into_iter().max().unwrap_or(theme::State::Present)
}
pub fn deps_summary(tab: &crate::deps::DepsTab) -> (theme::State, String) {
let mut by_name: std::collections::BTreeMap<&str, theme::State> = Default::default();
for row in tab.groups.iter().flat_map(|g| &g.rows) {
let state = crate::deps::state(row);
let slot = by_name.entry(row.name.as_str()).or_insert(state);
*slot = worst([*slot, state]);
}
let count = |want: theme::State| by_name.values().filter(|s| **s == want).count();
let installed = by_name.len() - count(theme::State::Absent);
let parts: Vec<String> = [
(installed, "installed"),
(count(theme::State::Drifted), "drifted"),
(count(theme::State::Absent), "missing"),
]
.into_iter()
.enumerate()
.filter(|(i, (n, _))| *i == 0 || *n > 0)
.map(|(_, (n, what))| format!("{n} {what}"))
.collect();
(worst(by_name.values().copied()), parts.join(" \u{b7} "))
}
pub fn members_summary(
rows: &[crate::members_editor::MemberRow],
) -> Option<(theme::State, String)> {
if rows.is_empty() {
return None;
}
let wanted = rows.iter().filter(|r| r.selected).count();
let installed = rows.iter().filter(|r| r.selected && r.installed).count();
let state = if rows.iter().any(|r| r.installed && !r.selected) {
theme::State::Drifted
} else if installed < wanted {
theme::State::Absent
} else {
theme::State::Present
};
Some((state, format!("{installed} of {wanted} installed")))
}
fn project_dir(project: &Project) -> PathBuf {
project
.pyproject_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| project.pyproject_path.clone())
}
pub fn no_environment_view<'a>(
project: &bombadil_core::model::Project,
index: usize,
) -> iced::Element<'a, Message> {
let folder = project
.pyproject_path
.parent()
.unwrap_or(&project.pyproject_path)
.display()
.to_string();
iced::widget::column![
iced::widget::row![
iced::widget::text(project.label.clone())
.font(theme::FONT_PROSE_SEMIBOLD)
.size(theme::DISPLAY),
iced::widget::Space::new().width(iced::Length::Fill),
icons::icon_button(
Icon::Folder,
"Open the project folder",
Some(Message::OpenFolderRequested(index)),
),
]
.align_y(iced::Alignment::Center),
iced::widget::text(folder)
.font(theme::FONT_DATA)
.size(theme::DATA)
.style(theme::text_muted),
iced::widget::Space::new().height(theme::SPACE_4),
iced::widget::text("This project has no environment.").size(theme::BODY),
iced::widget::text("Add one to sync it, open a terminal or open it in PyCharm.")
.size(theme::BODY)
.style(theme::text_muted),
iced::widget::button(iced::widget::text("Add environment").size(theme::BODY))
.on_press(Message::EnvironmentAdded(project.id))
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_primary),
]
.spacing(theme::SPACE_2)
.into()
}
pub fn view<'a>(
header: &Header,
index: usize,
busy: Option<(String, f32, bool)>,
tiles: Vec<Tile>,
) -> iced::Element<'a, Message> {
let sync_row = |glyph: iced::Element<'a, Message>, label: String| {
iced::widget::row![glyph, iced::widget::text(label).size(theme::BODY)]
.spacing(theme::SPACE_1)
.align_y(iced::Alignment::Center)
};
let sync: iced::Element<'a, Message> = match busy {
None => theme::tip(
iced::widget::button(sync_row(
icons::icon(Icon::Refresh, 14.0, theme::Swatch::Ink),
"Sync".to_string(),
))
.on_press(Message::SyncRequested(index))
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_primary),
format!("Sync ({})", shortcuts::keys(Shortcut::Sync)),
iced::widget::tooltip::Position::Left,
),
Some((label, angle, true)) => iced::widget::button(sync_row(
icons::spinner(angle, 14.0, theme::Swatch::Slate),
label,
))
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_primary)
.into(),
Some((label, _, false)) => iced::widget::tooltip(
iced::widget::button(sync_row(
icons::icon(Icon::Refresh, 14.0, theme::Swatch::Slate),
"Sync".to_string(),
))
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_primary),
iced::widget::container(
iced::widget::text(format!("Wait for {label} to finish")).size(theme::LABEL),
)
.padding(theme::SPACE_1)
.style(theme::panel),
iced::widget::tooltip::Position::Left,
)
.into(),
};
let terminal = icons::tile_button(
icons::Tile::Terminal,
format!(
"Open a terminal here, with the environment activated ({})",
shortcuts::keys(Shortcut::Terminal)
),
Some(Message::OpenTerminalHereRequested(index)),
);
let folder = icons::icon_button(
Icon::Folder,
"Open the project folder",
Some(Message::OpenFolderRequested(index)),
);
let pycharm = icons::tile_button(
icons::Tile::Pycharm,
"Open in PyCharm, in this folder, with this environment as the interpreter",
Some(Message::OpenPycharmRequested(index)),
);
let title_row = iced::widget::row![
iced::widget::text(header.label.clone())
.font(theme::FONT_PROSE_SEMIBOLD)
.size(theme::DISPLAY),
iced::widget::Space::new().width(iced::Length::Fill),
folder,
pycharm,
terminal,
sync,
]
.spacing(theme::SPACE_2)
.align_y(iced::Alignment::Center);
let mut card = iced::widget::row![].spacing(theme::SPACE_4);
for tile in tiles {
let glyph = theme::state_glyph(tile.state);
let body = iced::widget::column![
iced::widget::text(tile.label)
.size(theme::LABEL)
.style(theme::text_muted),
iced::widget::row![
theme::tip(
iced::widget::text(glyph.glyph.to_string())
.size(theme::BODY)
.style(theme::tinted(glyph.swatch)),
theme::state_meaning(tile.state),
iced::widget::tooltip::Position::Bottom,
),
iced::widget::text(tile.value).size(theme::BODY),
]
.spacing(theme::SPACE_1)
.align_y(iced::Alignment::Center),
]
.spacing(theme::SPACE_1);
let body = iced::widget::row![body]
.push(tile.sync.map(|message| {
iced::widget::button(iced::widget::text("Sync").size(theme::LABEL))
.on_press(message)
.padding([0.0, theme::SPACE_2])
.style(theme::button_primary)
}))
.spacing(theme::SPACE_2)
.align_y(iced::Alignment::End);
card = card.push(match tile.press {
Some(message) => iced::Element::from(
iced::widget::button(body)
.on_press(message)
.padding(0)
.style(theme::button_bare(theme::Swatch::Parchment)),
),
None => body.into(),
});
}
let card = iced::widget::container(card)
.width(iced::Length::Fill)
.padding([theme::SPACE_2, theme::SPACE_3])
.style(|t| iced::widget::container::Style {
border: iced::Border {
radius: theme::RADIUS_SURFACE.into(),
..iced::Border::default()
},
..theme::surface(t)
});
let path = |text: String| {
iced::widget::text(text)
.font(theme::FONT_DATA)
.size(theme::DATA)
.style(theme::text_muted)
};
let mut paths = iced::widget::column![path(header.path.display().to_string())];
if let Some(venv) = &header.venv_path {
paths = paths.push(path(format!("environment {}", venv.display())));
}
iced::widget::column![title_row, card, paths.spacing(theme::SPACE_1)]
.spacing(theme::SPACE_2)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::State;
fn dep(name: &str, constraint: Option<&str>, version: Option<&str>) -> crate::deps::DepRow {
crate::deps::DepRow {
name: name.into(),
constraint: constraint.map(Into::into),
version: version.map(Into::into),
declared: true,
}
}
fn tab(rows: Vec<crate::deps::DepRow>) -> crate::deps::DepsTab {
crate::deps::DepsTab {
groups: vec![crate::deps::DepGroup {
kind: crate::deps::GroupKind::Main,
rows,
}],
graph: None,
tree_note: None,
}
}
#[test]
fn the_environment_tile_says_why_it_drifted() {
let v = Some("3.11.9".to_string());
assert_eq!(
env_value(
VenvStatus::Present,
v.as_ref(),
State::Present,
Some(">=3.11")
),
"Python 3.11.9"
);
assert_eq!(
env_value(
VenvStatus::Present,
v.as_ref(),
State::Drifted,
Some(">=3.12")
),
"Python 3.11.9 does not satisfy >=3.12"
);
assert_eq!(
env_value(VenvStatus::Absent, None, State::Absent, None),
"no environment"
);
}
#[test]
fn the_worst_state_wins() {
assert_eq!(worst([]), State::Present);
assert_eq!(worst([State::Present, State::Absent]), State::Absent);
assert_eq!(
worst([State::Absent, State::Drifted, State::Present]),
State::Drifted
);
}
#[test]
fn the_dependencies_tile_counts_what_needs_attention() {
let all_good = tab(vec![dep("a", Some(">=1"), Some("1.2"))]);
assert_eq!(
deps_summary(&all_good),
(State::Present, "1 installed".to_string())
);
let mixed = tab(vec![
dep("a", Some(">=1"), Some("1.2")),
dep("b", Some(">=2"), Some("1.0")),
dep("c", Some(">=1"), None),
]);
assert_eq!(
deps_summary(&mixed),
(
State::Drifted,
"2 installed \u{b7} 1 drifted \u{b7} 1 missing".to_string()
)
);
let mut twice = mixed.clone();
twice.groups.push(twice.groups[0].clone());
assert_eq!(
deps_summary(&twice),
deps_summary(&mixed),
"a package in two groups counts once"
);
}
fn member(selected: bool, installed: bool) -> crate::members_editor::MemberRow {
crate::members_editor::MemberRow {
path: "m".into(),
name: Some("m".into()),
origin: bombadil_core::model::MemberOrigin::Discovered,
selected,
installed,
}
}
#[test]
fn the_members_tile_counts_the_wanted_ones() {
assert_eq!(members_summary(&[]), None, "no members, no tile");
assert_eq!(
members_summary(&[member(true, true), member(false, false)]),
Some((State::Present, "1 of 1 installed".to_string())),
"a deselected, uninstalled member is not a problem"
);
assert_eq!(
members_summary(&[member(true, true), member(true, false)]),
Some((State::Absent, "1 of 2 installed".to_string()))
);
assert_eq!(
members_summary(&[member(true, true), member(false, true)]).map(|s| s.0),
Some(State::Drifted),
"a sync would remove one"
);
}
#[test]
fn the_git_line_says_branch_divergence_and_changes() {
use bombadil_core::git::GitStatus;
let git = GitStatus {
branch: "main".into(),
upstream: Some("origin/trunk".into()),
ahead: 1,
behind: 2,
modified: 3,
untracked: 1,
..GitStatus::default()
};
assert_eq!(
git_summary(&git),
"main \u{2192} origin/trunk \u{b7} \u{2191}1 \u{2193}2 \u{b7} 3 modified, 1 untracked"
);
let clean = GitStatus {
branch: "abc12345".into(),
detached: true,
..GitStatus::default()
};
assert_eq!(git_summary(&clean), "detached at abc12345 \u{b7} clean");
}
#[test]
fn a_branch_tracking_its_namesake_does_not_repeat_the_name() {
use bombadil_core::git::GitStatus;
let git = GitStatus {
branch: "feature/long-name".into(),
upstream: Some("origin/feature/long-name".into()),
..GitStatus::default()
};
assert_eq!(git_summary(&git), "feature/long-name \u{b7} clean");
let elsewhere = GitStatus {
branch: "main".into(),
upstream: Some("origin/trunk".into()),
..GitStatus::default()
};
assert_eq!(
git_summary(&elsewhere),
"main \u{2192} origin/trunk \u{b7} clean"
);
}
#[test]
fn the_header_carries_the_active_environments_path() {
let path = PathBuf::from("/home/me/envs/api-312");
let header = header(&project("api"), Some(path.clone()), &|_| {
Some("3.12.1".into())
});
assert_eq!(header.venv_path, Some(path));
assert_ne!(
header.venv_path.as_ref(),
Some(&header.path),
"the environment path is not the project path -- showing one twice \
would say nothing"
);
}
#[test]
fn a_project_with_no_environments_carries_no_path() {
let header = header(&project("api"), None, &|_| None);
assert_eq!(header.venv_path, None);
}
#[test]
fn a_project_with_a_venv_shows_its_python_version() {
let header = header(&project("api"), None, &|_| Some("3.12.1".into()));
assert_eq!(header.python_version.as_deref(), Some("3.12.1"));
assert_eq!(header.venv_status, VenvStatus::Present);
}
#[test]
fn a_project_without_a_venv_reports_no_environment() {
let header = header(&project("api"), None, &|_| None);
assert_eq!(header.python_version, None);
assert_eq!(header.venv_status, VenvStatus::Absent);
}
#[test]
fn the_displayed_path_is_the_project_directory_not_the_manifest_file() {
let header = header(&project("api"), None, &|_| None);
assert_eq!(header.path, PathBuf::from("/p/api"));
assert_ne!(header.path, PathBuf::from("/p/api/pyproject.toml"));
}
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()
}
}
}