#[cfg(any(target_os = "macos", target_os = "windows"))]
mod native;
use crate::app::store::{Connection, Store};
use crate::docker::{Action, ContainerInfo, Task};
const MAX_CONTAINERS: usize = 20;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Entry {
Item {
id: String,
label: String,
enabled: bool,
},
Separator,
Menu {
label: String,
items: Vec<Entry>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrayAction {
Open,
OpenContainer(String),
Run(Vec<Task>),
Quit,
}
fn item(id: impl Into<String>, label: impl Into<String>) -> Entry {
Entry::Item {
id: id.into(),
label: label.into(),
enabled: true,
}
}
fn note(label: impl Into<String>) -> Entry {
Entry::Item {
id: String::new(),
label: label.into(),
enabled: false,
}
}
pub fn title(store: &Store) -> String {
match store.connection {
Connection::Connected { .. } => store.running_count().to_string(),
_ => "–".into(),
}
}
pub fn model(store: &Store) -> Vec<Entry> {
let mut menu = vec![item("open", "Open Ferrodock"), Entry::Separator];
match &store.connection {
Connection::Connected { version } => {
menu.push(note(format!(
"Docker {version} · {} running",
store.running_count()
)));
}
_ => menu.push(note("Docker isn't running")),
}
if !store.projects.is_empty() {
menu.push(Entry::Separator);
let projects = store
.projects
.iter()
.map(|project| {
let running = project.running();
let mut items = vec![note(format!(
"{running} of {} running",
project.containers.len()
))];
if running < project.containers.len() {
items.push(item(format!("up:{}", project.name), "Start all"));
}
if running > 0 {
items.push(item(format!("down:{}", project.name), "Stop all"));
}
Entry::Menu {
label: format!("{} {}", dot(running > 0), project.name),
items,
}
})
.collect();
menu.push(Entry::Menu {
label: "Compose".into(),
items: projects,
});
}
if !store.containers.is_empty() {
menu.push(Entry::Separator);
let mut containers: Vec<&ContainerInfo> = store.containers.iter().collect();
containers.sort_by_key(|c| !c.is_active());
for c in containers.iter().take(MAX_CONTAINERS) {
menu.push(Entry::Menu {
label: format!("{} {}", dot(c.is_active()), c.name),
items: container_items(c),
});
}
if containers.len() > MAX_CONTAINERS {
menu.push(note(format!(
"{} more in the window",
containers.len() - MAX_CONTAINERS
)));
}
}
menu.extend([Entry::Separator, item("quit", "Quit Ferrodock")]);
menu
}
fn dot(running: bool) -> &'static str {
if running { "●" } else { "○" }
}
fn container_items(c: &ContainerInfo) -> Vec<Entry> {
let mut items = Vec::new();
if c.is_active() {
items.push(item(format!("stop:{}", c.id), "Stop"));
items.push(item(format!("restart:{}", c.id), "Restart"));
} else {
items.push(item(format!("start:{}", c.id), "Start"));
}
items.push(Entry::Separator);
items.push(item(format!("show:{}", c.id), "Show in Ferrodock"));
items
}
pub fn action(store: &Store, id: &str) -> Option<TrayAction> {
let task = |action: Action, c: &ContainerInfo| Task {
action,
id: c.id.clone(),
name: c.name.clone(),
};
match id.split_once(':') {
None => match id {
"open" => Some(TrayAction::Open),
"quit" => Some(TrayAction::Quit),
_ => None,
},
Some((verb @ ("up" | "down"), name)) => {
let project = store.projects.iter().find(|p| p.name == name)?;
let (action, wanted) = if verb == "up" {
(Action::Start, false)
} else {
(Action::Stop, true)
};
let tasks = project
.containers
.iter()
.filter(|c| c.is_active() == wanted)
.map(|c| task(action, c))
.collect();
Some(TrayAction::Run(tasks))
}
Some((verb, container)) => {
let c = store.container(container)?;
let action = match verb {
"start" => Action::Start,
"stop" => Action::Stop,
"restart" => Action::Restart,
"show" => return Some(TrayAction::OpenContainer(c.id.clone())),
_ => return None,
};
Some(TrayAction::Run(vec![task(action, c)]))
}
}
}
pub struct Tray {
#[cfg(any(target_os = "macos", target_os = "windows"))]
native: native::NativeTray,
shown: Vec<Entry>,
title: String,
}
impl Tray {
pub const SUPPORTED: bool = cfg!(any(target_os = "macos", target_os = "windows"));
pub fn new(ctx: &eframe::egui::Context) -> Option<Self> {
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
match native::NativeTray::new(ctx) {
Ok(native) => Some(Self {
native,
shown: Vec::new(),
title: String::new(),
}),
Err(e) => {
eprintln!("ferrodock: no tray icon: {e}");
None
}
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = ctx;
None
}
}
pub fn update(&mut self, store: &Store) -> Vec<TrayAction> {
let menu = model(store);
let title = title(store);
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
if menu != self.shown {
self.native.set_menu(&menu);
}
if title != self.title {
self.native.set_title(&title);
}
}
self.shown = menu;
self.title = title;
#[cfg(any(target_os = "macos", target_os = "windows"))]
let clicked = self.native.clicked();
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let clicked: Vec<String> = Vec::new();
clicked.iter().filter_map(|id| action(store, id)).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::docker::ContainerInfo;
fn container(id: &str, name: &str, running: bool) -> ContainerInfo {
ContainerInfo {
id: id.into(),
name: name.into(),
state: if running { "running" } else { "exited" }.into(),
..Default::default()
}
}
fn store() -> Store {
let mut store = Store::new();
store.connection = Connection::Connected {
version: "29.0".into(),
};
store.containers = vec![container("a1", "api", false), container("w1", "web", true)];
store
}
#[test]
fn lists_running_containers_first() {
let menu = model(&store());
let labels: Vec<&str> = menu
.iter()
.filter_map(|e| match e {
Entry::Menu { label, .. } => Some(label.as_str()),
_ => None,
})
.collect();
assert_eq!(labels, ["● web", "○ api"]);
assert!(menu.contains(¬e("Docker 29.0 · 1 running")));
assert_eq!(title(&store()), "1");
}
#[test]
fn clicks_become_actions() {
let store = store();
assert_eq!(action(&store, "open"), Some(TrayAction::Open));
let Some(TrayAction::Run(tasks)) = action(&store, "stop:w1") else {
panic!("stop should run a task");
};
assert_eq!(
(tasks[0].action, tasks[0].name.as_str()),
(Action::Stop, "web")
);
assert_eq!(
action(&store, "show:a1"),
Some(TrayAction::OpenContainer("a1".into()))
);
assert_eq!(action(&store, "stop:gone"), None);
}
}