pub mod disk_monitor;
pub mod git_poller;
pub mod plugins_upkeep;
pub mod system_monitor;
pub mod widgets;
#[cfg(test)]
mod widget_click_tests;
use std::collections::HashMap;
use std::time::{Duration, Instant};
use crate::agent_usage::store::UsageStore;
use crate::agent_usage::update::UpdateRunner;
use crate::badge::SessionVariables;
use crate::config::{Config, StatusBarPosition, StatusBarSection, StatusBarWidgetConfig};
use disk_monitor::DiskMonitor;
use git_poller::GitBranchPoller;
use par_term_scripting::plugin_manager::{PluginHost, WarnOnce};
use system_monitor::SystemMonitor;
use widgets::{WidgetContext, sorted_widgets_for_section, widget_text};
pub use git_poller::GitStatus;
const AGENT_USAGE_MIN_REFRESH: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StatusBarAction {
ShowUpdateDialog,
OpenAgentUsagePanel,
OpenAgentPalette,
}
pub struct StatusBarUI {
system_monitor: SystemMonitor,
disk_monitor: DiskMonitor,
git_poller: GitBranchPoller,
last_mouse_activity: Instant,
visible: bool,
last_valid_time_format: String,
pub update_available_version: Option<String>,
pub agent_roster_summary: Option<String>,
pub agent_roster_tooltip: Option<String>,
pub broadcast_receivers: Option<usize>,
pub(crate) usage: UsageStore,
usage_update: UpdateRunner,
plugins: PluginHost,
plugins_last_discovery: Option<Instant>,
plugin_settings_warned: WarnOnce,
plugin_observer_ids: HashMap<
(String, crate::tab::TabId, Option<crate::pane::PaneId>),
par_term_emu_core_rust::observer::ObserverId,
>,
}
impl StatusBarUI {
pub fn new() -> Self {
Self {
system_monitor: SystemMonitor::new(),
disk_monitor: DiskMonitor::new(),
git_poller: GitBranchPoller::new(),
last_mouse_activity: Instant::now(),
visible: true,
last_valid_time_format: "%H:%M:%S".to_string(),
update_available_version: None,
agent_roster_summary: None,
agent_roster_tooltip: None,
broadcast_receivers: None,
usage: UsageStore::new(crate::agent_usage::default_records_dir()),
usage_update: UpdateRunner::new(),
plugins: PluginHost::new(),
plugins_last_discovery: None,
plugin_settings_warned: WarnOnce::default(),
plugin_observer_ids: HashMap::new(),
}
}
pub(crate) fn usage_snapshot(&self) -> &crate::agent_usage::store::UsageSnapshot {
self.usage.snapshot()
}
pub(crate) fn refresh_usage_now(&mut self) {
self.usage.refresh_now();
self.usage_update.trigger_if_idle();
}
pub fn signal_shutdown(&self) {
self.system_monitor.signal_stop();
self.disk_monitor.signal_stop();
self.git_poller.signal_stop();
}
pub(crate) fn update_agent_usage(&mut self, config: &Config) {
if !config.agent_usage.agent_usage_enabled {
return;
}
self.usage.poll();
self.usage.set_hidden(config.agent_usage.hidden_set());
self.usage.set_extra_dirs(
config
.agent_usage
.agent_usage_extra_records_dirs
.iter()
.map(|p| crate::agent_usage::expand_tilde(p))
.collect(),
);
self.usage_update
.configure(config.agent_usage.agent_usage_update_command.clone());
let interval = Duration::from_secs(
config
.agent_usage
.agent_usage_refresh_interval_sec
.max(AGENT_USAGE_MIN_REFRESH.as_secs()),
);
if self.usage.tick(interval) {
self.usage_update.trigger_if_idle();
}
}
pub fn height(&self, config: &Config, is_fullscreen: bool) -> f32 {
if !config.status_bar.status_bar_enabled || self.should_hide(config, is_fullscreen) {
0.0
} else {
config.status_bar.status_bar_height
}
}
pub fn should_hide(&self, config: &Config, is_fullscreen: bool) -> bool {
if !config.status_bar.status_bar_enabled {
return true;
}
if config.status_bar.status_bar_auto_hide_fullscreen && is_fullscreen {
return true;
}
if config.status_bar.status_bar_auto_hide_mouse_inactive {
let elapsed = self.last_mouse_activity.elapsed().as_secs_f32();
if elapsed > config.status_bar.status_bar_mouse_inactive_timeout {
return true;
}
}
false
}
pub fn on_mouse_activity(&mut self) {
self.last_mouse_activity = Instant::now();
self.visible = true;
}
pub fn sync_monitor_state(&self, config: &Config) {
if !config.status_bar.status_bar_enabled {
if self.system_monitor.is_running() {
self.system_monitor.stop();
}
if self.disk_monitor.is_running() {
self.disk_monitor.stop();
}
if self.git_poller.is_running() {
self.git_poller.stop();
}
return;
}
let needs_monitor = config
.status_bar
.status_bar_widgets
.iter()
.any(|w| w.enabled && w.id.needs_system_monitor());
if needs_monitor && !self.system_monitor.is_running() {
self.system_monitor
.start(config.status_bar.status_bar_system_poll_interval);
} else if !needs_monitor && self.system_monitor.is_running() {
self.system_monitor.stop();
}
let needs_disk = config
.status_bar
.status_bar_widgets
.iter()
.any(|w| w.enabled && w.id.needs_disk_monitor());
self.disk_monitor
.set_follow_cwd(config.status_bar.status_bar_disk_follow_cwd);
if needs_disk && !self.disk_monitor.is_running() {
self.disk_monitor
.start(config.status_bar.status_bar_disk_poll_interval);
} else if !needs_disk && self.disk_monitor.is_running() {
self.disk_monitor.stop();
}
let needs_git = config
.status_bar
.status_bar_widgets
.iter()
.any(|w| w.enabled && w.id == crate::config::WidgetId::GitBranch);
if needs_git && !self.git_poller.is_running() {
self.git_poller
.start(config.status_bar.status_bar_git_poll_interval);
} else if !needs_git && self.git_poller.is_running() {
self.git_poller.stop();
}
}
pub fn render(
&mut self,
ctx: &egui::Context,
config: &Config,
session_vars: &SessionVariables,
is_fullscreen: bool,
) -> (f32, Option<StatusBarAction>) {
if !config.status_bar.status_bar_enabled || self.should_hide(config, is_fullscreen) {
return (0.0, None);
}
let cwd = if session_vars.path.is_empty() {
None
} else {
Some(session_vars.path.as_str())
};
self.git_poller.set_cwd(cwd);
self.disk_monitor.set_cwd(cwd);
{
use chrono::format::strftime::StrftimeItems;
let valid = !config.status_bar.status_bar_time_format.is_empty()
&& StrftimeItems::new(&config.status_bar.status_bar_time_format)
.all(|item| !matches!(item, chrono::format::Item::Error));
if valid {
self.last_valid_time_format = config.status_bar.status_bar_time_format.clone();
}
}
let git_status = self.git_poller.status();
let disk_data = self.disk_monitor.data();
let widget_ctx = WidgetContext {
session_vars: session_vars.clone(),
system_data: self.system_monitor.data(),
git_branch: git_status.branch,
git_ahead: git_status.ahead,
git_behind: git_status.behind,
git_dirty: git_status.dirty,
git_show_status: config.status_bar.status_bar_git_show_status,
time_format: self.last_valid_time_format.clone(),
update_available_version: self.update_available_version.clone(),
disk_free_percent: disk_data.free_percent,
disk_free_bytes: disk_data.free_bytes,
disk_total_bytes: disk_data.total_bytes,
agent_usage_summary: self.usage.summary_line(),
agent_roster_summary: self.agent_roster_summary.clone(),
agent_roster_tooltip: self.agent_roster_tooltip.clone(),
broadcast_receivers: self.broadcast_receivers,
plugin_texts: self.plugins.widget_texts().clone(),
};
let bar_height = config.status_bar.status_bar_height;
let [bg_r, bg_g, bg_b] = config.status_bar.status_bar_bg_color;
let bg_alpha = (config.status_bar.status_bar_bg_alpha * 255.0) as u8;
let bg_color = egui::Color32::from_rgba_unmultiplied(bg_r, bg_g, bg_b, bg_alpha);
let [fg_r, fg_g, fg_b] = config.status_bar.status_bar_fg_color;
let fg_color = egui::Color32::from_rgb(fg_r, fg_g, fg_b);
let font_size = config.status_bar.status_bar_font_size;
let separator = &config.status_bar.status_bar_separator;
let sep_color = fg_color.linear_multiply(0.4);
let h_margin: f32 = 8.0; let v_margin: f32 = 2.0; let scrollbar_reserved = config.scrollbar.scrollbar_width + 2.0;
let viewport = ctx.input(|i| i.viewport_rect());
let content_width = (viewport.width() - scrollbar_reserved - h_margin * 2.0).max(0.0);
let content_height = (bar_height - v_margin * 2.0).max(0.0);
let bar_pos = match config.status_bar.status_bar_position {
StatusBarPosition::Top => egui::pos2(0.0, 0.0),
StatusBarPosition::Bottom => egui::pos2(0.0, viewport.height() - bar_height),
};
let frame = egui::Frame::NONE
.fill(bg_color)
.inner_margin(egui::Margin::symmetric(h_margin as i8, v_margin as i8));
let make_rich_text = |text: &str| -> egui::RichText {
egui::RichText::new(text)
.color(fg_color)
.size(font_size)
.monospace()
};
let make_sep = |sep: &str| -> egui::RichText {
egui::RichText::new(sep)
.color(sep_color)
.size(font_size)
.monospace()
};
let roster_tooltip = self.agent_roster_tooltip.clone();
let render_widget = |ui: &mut egui::Ui,
w: &StatusBarWidgetConfig,
text: &str|
-> Option<StatusBarAction> {
use crate::config::WidgetId;
let (rich, action) = match w.id {
WidgetId::UpdateAvailable => (
egui::RichText::new(text)
.color(egui::Color32::from_rgb(255, 200, 50))
.size(font_size)
.monospace(),
StatusBarAction::ShowUpdateDialog,
),
WidgetId::AgentUsage => {
(make_rich_text(text), StatusBarAction::OpenAgentUsagePanel)
}
WidgetId::AgentRoster => (make_rich_text(text), StatusBarAction::OpenAgentPalette),
_ => {
ui.label(make_rich_text(text));
return None;
}
};
let response = ui.add(egui::Label::new(rich).sense(egui::Sense::click()));
if w.id == WidgetId::AgentRoster
&& let Some(tooltip) = roster_tooltip.as_deref()
&& !tooltip.is_empty()
{
return response.on_hover_text(tooltip).clicked().then_some(action);
}
response.clicked().then_some(action)
};
let mut action: Option<StatusBarAction> = None;
egui::Area::new(egui::Id::new("status_bar"))
.fixed_pos(bar_pos)
.order(egui::Order::Background)
.interactable(true)
.show(ctx, |ui| {
ui.set_max_width(content_width + h_margin * 2.0);
ui.set_max_height(bar_height);
frame.show(ui, |ui| {
ui.set_min_size(egui::vec2(content_width, content_height));
ui.set_max_size(egui::vec2(content_width, content_height));
ui.horizontal_centered(|ui| {
ui.set_clip_rect(ui.max_rect());
let left_widgets = sorted_widgets_for_section(
&config.status_bar.status_bar_widgets,
StatusBarSection::Left,
);
let mut first = true;
for w in &left_widgets {
let text = widget_text(&w.id, &widget_ctx, w.format.as_deref());
if text.is_empty() {
continue;
}
if !first {
ui.label(make_sep(separator));
}
first = false;
if let Some(a) = render_widget(ui, w, &text) {
action = Some(a);
}
}
let center_widgets = sorted_widgets_for_section(
&config.status_bar.status_bar_widgets,
StatusBarSection::Center,
);
if !center_widgets.is_empty() {
ui.with_layout(
egui::Layout::centered_and_justified(egui::Direction::LeftToRight),
|ui| {
let mut first = true;
for w in ¢er_widgets {
let text =
widget_text(&w.id, &widget_ctx, w.format.as_deref());
if text.is_empty() {
continue;
}
if !first {
ui.label(make_sep(separator));
}
first = false;
if let Some(a) = render_widget(ui, w, &text) {
action = Some(a);
}
}
},
);
}
let right_widgets = sorted_widgets_for_section(
&config.status_bar.status_bar_widgets,
StatusBarSection::Right,
);
if !right_widgets.is_empty() {
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
let mut first = true;
for w in right_widgets.iter().rev() {
let text =
widget_text(&w.id, &widget_ctx, w.format.as_deref());
if text.is_empty() {
continue;
}
if !first {
ui.label(make_sep(separator));
}
first = false;
if let Some(a) = render_widget(ui, w, &text) {
action = Some(a);
}
}
},
);
}
});
});
});
(bar_height, action)
}
}
impl Default for StatusBarUI {
fn default() -> Self {
Self::new()
}
}
impl Drop for StatusBarUI {
fn drop(&mut self) {
self.system_monitor.stop();
self.disk_monitor.stop();
}
}