use crate::event::Rect;
use crate::types::{Color, Icon, Modifiers, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActivityBar {
pub id: WidgetId,
pub top_items: Vec<ActivityItem>,
#[serde(default)]
pub bottom_items: Vec<ActivityItem>,
#[serde(default)]
pub active_accent: Option<Color>,
#[serde(default)]
pub selection_bg: Option<Color>,
#[serde(default)]
pub is_keyboard_focused: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActivityItem {
pub id: WidgetId,
pub icon: Icon,
#[serde(default)]
pub tooltip: String,
#[serde(default)]
pub is_active: bool,
#[serde(default)]
pub is_keyboard_selected: bool,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
pub struct ActivityBarStyle {
#[serde(default)]
pub active_bg: Option<Color>,
#[serde(default)]
pub icon_size_px: Option<f32>,
}
impl ActivityBarStyle {
pub fn new() -> Self {
Self::default()
}
pub fn with_active_bg(mut self, color: Color) -> Self {
self.active_bg = Some(color);
self
}
pub fn with_icon_size_px(mut self, size_px: f32) -> Self {
self.icon_size_px = Some(size_px);
self
}
pub fn resolved_icon_size_px(&self) -> f32 {
self.icon_size_px.unwrap_or(DEFAULT_ACTIVITY_ICON_SIZE_PX)
}
}
pub const DEFAULT_ACTIVITY_ICON_SIZE_PX: f32 = 24.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActivitySide {
Top,
Bottom,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VisibleActivityItem {
pub side: ActivitySide,
pub item_idx: usize,
pub bounds: Rect,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ActivityBarHit {
Item(WidgetId),
Empty,
}
#[derive(Debug, Clone)]
pub struct ActivityBarRowHit {
pub y_start: f32,
pub y_end: f32,
pub id: WidgetId,
pub tooltip: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ActivityBarLayout {
pub viewport_width: f32,
pub viewport_height: f32,
pub visible_items: Vec<VisibleActivityItem>,
pub hit_regions: Vec<(Rect, ActivityBarHit)>,
}
impl ActivityBarLayout {
pub fn hit_test(&self, x: f32, y: f32) -> ActivityBarHit {
for (rect, hit) in &self.hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return hit.clone();
}
}
ActivityBarHit::Empty
}
}
impl ActivityBar {
pub fn layout(
&self,
viewport_width: f32,
viewport_height: f32,
item_height: f32,
) -> ActivityBarLayout {
let mut visible_items: Vec<VisibleActivityItem> = Vec::new();
let mut hit_regions: Vec<(Rect, ActivityBarHit)> = Vec::new();
if item_height <= 0.0 || viewport_height <= 0.0 {
return ActivityBarLayout {
viewport_width,
viewport_height,
visible_items,
hit_regions,
};
}
let bottom_count = self.bottom_items.len();
let bottom_y_start = (viewport_height - (bottom_count as f32) * item_height).max(0.0);
for (i, item) in self.bottom_items.iter().enumerate() {
let y = bottom_y_start + (i as f32) * item_height;
if y >= viewport_height {
break;
}
let height = item_height.min(viewport_height - y);
if height <= 0.0 {
break;
}
let bounds = Rect::new(0.0, y, viewport_width, height);
visible_items.push(VisibleActivityItem {
side: ActivitySide::Bottom,
item_idx: i,
bounds,
});
hit_regions.push((bounds, ActivityBarHit::Item(item.id)));
}
let top_limit = bottom_y_start;
for (i, item) in self.top_items.iter().enumerate() {
let y = (i as f32) * item_height;
if y >= top_limit {
break;
}
let height = item_height.min(top_limit - y);
if height <= 0.0 {
break;
}
let bounds = Rect::new(0.0, y, viewport_width, height);
visible_items.push(VisibleActivityItem {
side: ActivitySide::Top,
item_idx: i,
bounds,
});
hit_regions.push((bounds, ActivityBarHit::Item(item.id)));
}
ActivityBarLayout {
viewport_width,
viewport_height,
visible_items,
hit_regions,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ActivityBarEvent {
ItemClicked { id: WidgetId },
KeyPressed { key: String, modifiers: Modifiers },
}
#[cfg(any(
feature = "tui",
feature = "gtk",
all(feature = "macos", target_os = "macos"),
feature = "win"
))]
pub(crate) fn key_to_activity_bar_string(key: &crate::event::Key) -> String {
use crate::event::{Key, NamedKey};
match key {
Key::Char(c) => c.to_string(),
Key::Named(named) => {
let s = match named {
NamedKey::Escape => "Escape",
NamedKey::Tab => "Tab",
NamedKey::BackTab => "BackTab",
NamedKey::Enter => "Enter",
NamedKey::Backspace => "Backspace",
NamedKey::Delete => "Delete",
NamedKey::Insert => "Insert",
NamedKey::Home => "Home",
NamedKey::End => "End",
NamedKey::PageUp => "PageUp",
NamedKey::PageDown => "PageDown",
NamedKey::Up => "Up",
NamedKey::Down => "Down",
NamedKey::Left => "Left",
NamedKey::Right => "Right",
NamedKey::F(1) => "F1",
NamedKey::F(2) => "F2",
NamedKey::F(3) => "F3",
NamedKey::F(4) => "F4",
NamedKey::F(5) => "F5",
NamedKey::F(6) => "F6",
NamedKey::F(7) => "F7",
NamedKey::F(8) => "F8",
NamedKey::F(9) => "F9",
NamedKey::F(10) => "F10",
NamedKey::F(11) => "F11",
NamedKey::F(12) => "F12",
NamedKey::F(_) => "F?",
NamedKey::CapsLock => "CapsLock",
NamedKey::NumLock => "NumLock",
NamedKey::ScrollLock => "ScrollLock",
NamedKey::Menu => "Menu",
};
s.to_string()
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{
ActivityBar, ActivityBarLayout, ActivityBarRowHit, ActivityBarStyle, ActivitySide,
};
use crate::event::Rect;
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
pub(crate) fn paint(
bar: &ActivityBar,
layout: &ActivityBarLayout,
style: &ActivityBarStyle,
surface: &mut dyn PaintSurface,
theme: &Theme,
hovered_idx: Option<usize>,
nerd_fonts_enabled: bool,
) -> Vec<ActivityBarRowHit> {
let width = layout.viewport_width;
let height = layout.viewport_height;
surface.surface_fill_rect(Rect::new(0.0, 0.0, width, height), theme.tab_bar_bg);
surface.surface_fill_rect(Rect::new(width - 1.0, 0.0, 1.0, height), theme.separator);
let hover_bg = theme.tab_bar_bg.lighten(0.10);
let mut regions: Vec<ActivityBarRowHit> = Vec::new();
for (flat_idx, vi) in layout.visible_items.iter().enumerate() {
let y = vi.bounds.y;
let row_h = vi.bounds.height;
let item = match vi.side {
ActivitySide::Top => &bar.top_items[vi.item_idx],
ActivitySide::Bottom => &bar.bottom_items[vi.item_idx],
};
let is_hovered = hovered_idx == Some(flat_idx);
if item.is_active {
if let Some(bg) = style.active_bg {
surface.surface_fill_rect(Rect::new(0.0, y, width, row_h), bg);
}
}
if is_hovered {
surface.surface_fill_rect(Rect::new(0.0, y, width, row_h), hover_bg);
}
if item.is_keyboard_selected {
let sel_bg = bar
.selection_bg
.unwrap_or_else(|| theme.tab_bar_bg.lighten(0.20));
surface.surface_fill_rect(Rect::new(0.0, y, width, row_h), sel_bg);
}
if item.is_active {
if let Some(accent) = bar.active_accent {
surface.surface_fill_rect(Rect::new(0.0, y, 2.0, row_h), accent);
}
}
let icon_str = if nerd_fonts_enabled {
item.icon.glyph.as_str()
} else {
item.icon.fallback.as_str()
};
let (iw, ih) = surface.surface_measure_text(icon_str);
let fg = if item.is_active || is_hovered || item.is_keyboard_selected {
theme.foreground
} else {
theme.inactive_fg
};
surface.surface_draw_text_run(
Rect::new(
(width - iw) / 2.0,
y + (row_h - ih) / 2.0,
iw.max(0.0),
ih.max(0.0),
),
icon_str,
fg,
);
regions.push(ActivityBarRowHit {
y_start: y,
y_end: y + row_h,
id: item.id,
tooltip: item.tooltip.clone(),
});
}
regions
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn activity_bar_roundtrip_serde() {
let bar = ActivityBar {
id: WidgetId::new("main-activity-bar"),
top_items: vec![
ActivityItem {
id: WidgetId::new("activity:explorer"),
icon: "\u{f07c}".into(),
tooltip: "Explorer".to_string(),
is_active: true,
is_keyboard_selected: false,
},
ActivityItem {
id: WidgetId::new("activity:search"),
icon: "\u{f422}".into(),
tooltip: "Search".to_string(),
is_active: false,
is_keyboard_selected: true,
},
],
bottom_items: vec![ActivityItem {
id: WidgetId::new("activity:settings"),
icon: "\u{f013}".into(),
tooltip: "Settings".to_string(),
is_active: false,
is_keyboard_selected: false,
}],
active_accent: Some(Color::rgb(120, 180, 255)),
selection_bg: Some(Color::rgb(80, 80, 80)),
is_keyboard_focused: false,
};
let json = serde_json::to_string(&bar).unwrap();
let back: ActivityBar = serde_json::from_str(&json).unwrap();
assert_eq!(bar, back);
}
#[test]
fn activity_bar_style_roundtrip_and_defaults_to_none_for_pre_658_payloads() {
let style = ActivityBarStyle::new().with_active_bg(Color::rgb(49, 50, 51));
let json = serde_json::to_string(&style).unwrap();
let back: ActivityBarStyle = serde_json::from_str(&json).unwrap();
assert_eq!(style, back);
let old_json = "{}";
let defaulted: ActivityBarStyle = serde_json::from_str(old_json).unwrap();
assert_eq!(defaulted, ActivityBarStyle::default());
assert_eq!(defaulted.active_bg, None);
}
#[test]
fn activity_bar_style_icon_size_roundtrips_and_defaults() {
let style = ActivityBarStyle::new().with_icon_size_px(18.0);
let json = serde_json::to_string(&style).unwrap();
let back: ActivityBarStyle = serde_json::from_str(&json).unwrap();
assert_eq!(style, back);
assert_eq!(back.resolved_icon_size_px(), 18.0);
let old_json = "{}";
let defaulted: ActivityBarStyle = serde_json::from_str(old_json).unwrap();
assert_eq!(defaulted.icon_size_px, None);
assert_eq!(
defaulted.resolved_icon_size_px(),
DEFAULT_ACTIVITY_ICON_SIZE_PX
);
}
#[test]
fn activity_bar_event_roundtrip_serde() {
let events = vec![
ActivityBarEvent::ItemClicked {
id: WidgetId::new("activity:git"),
},
ActivityBarEvent::KeyPressed {
key: "Escape".to_string(),
modifiers: Modifiers::default(),
},
];
for event in &events {
let json = serde_json::to_string(event).unwrap();
let back: ActivityBarEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, &back);
}
}
fn make_activity_item(id: &str, icon: char) -> ActivityItem {
ActivityItem {
id: WidgetId::new(id),
icon: icon.to_string().into(),
tooltip: String::new(),
is_active: false,
is_keyboard_selected: false,
}
}
#[test]
fn activity_bar_layout_empty() {
let bar = ActivityBar {
id: WidgetId::new("a"),
top_items: vec![],
bottom_items: vec![],
active_accent: None,
selection_bg: None,
is_keyboard_focused: false,
};
let layout = bar.layout(3.0, 20.0, 1.0);
assert_eq!(layout.visible_items.len(), 0);
assert_eq!(layout.hit_test(1.0, 5.0), ActivityBarHit::Empty);
}
#[test]
fn activity_bar_layout_top_only() {
let bar = ActivityBar {
id: WidgetId::new("a"),
top_items: vec![
make_activity_item("activity:explorer", 'E'),
make_activity_item("activity:search", 'S'),
],
bottom_items: vec![],
active_accent: None,
selection_bg: None,
is_keyboard_focused: false,
};
let layout = bar.layout(3.0, 10.0, 1.0);
assert_eq!(layout.visible_items.len(), 2);
assert_eq!(layout.visible_items[0].side, ActivitySide::Top);
assert_eq!(layout.visible_items[0].bounds.y, 0.0);
assert_eq!(layout.visible_items[1].bounds.y, 1.0);
match layout.hit_test(1.0, 0.5) {
ActivityBarHit::Item(id) => assert_eq!(id.as_str(), "activity:explorer"),
_ => panic!("expected explorer hit"),
}
}
#[test]
fn activity_bar_layout_bottom_pinned() {
let bar = ActivityBar {
id: WidgetId::new("a"),
top_items: vec![make_activity_item("activity:explorer", 'E')],
bottom_items: vec![make_activity_item("activity:settings", 'G')],
active_accent: None,
selection_bg: None,
is_keyboard_focused: false,
};
let layout = bar.layout(3.0, 10.0, 1.0);
assert_eq!(layout.visible_items.len(), 2);
let top = layout
.visible_items
.iter()
.find(|v| v.side == ActivitySide::Top)
.unwrap();
let bot = layout
.visible_items
.iter()
.find(|v| v.side == ActivitySide::Bottom)
.unwrap();
assert_eq!(top.bounds.y, 0.0);
assert_eq!(bot.bounds.y, 9.0);
match layout.hit_test(1.0, 0.5) {
ActivityBarHit::Item(id) => assert_eq!(id.as_str(), "activity:explorer"),
_ => panic!(),
}
match layout.hit_test(1.0, 9.5) {
ActivityBarHit::Item(id) => assert_eq!(id.as_str(), "activity:settings"),
_ => panic!(),
}
}
#[test]
fn activity_bar_layout_bottom_wins_on_collision() {
let bar = ActivityBar {
id: WidgetId::new("a"),
top_items: (0..5)
.map(|i| make_activity_item(&format!("top:{i}"), 'T'))
.collect(),
bottom_items: (0..3)
.map(|i| make_activity_item(&format!("bot:{i}"), 'B'))
.collect(),
active_accent: None,
selection_bg: None,
is_keyboard_focused: false,
};
let layout = bar.layout(3.0, 6.0, 1.0);
let top_count = layout
.visible_items
.iter()
.filter(|v| v.side == ActivitySide::Top)
.count();
let bot_count = layout
.visible_items
.iter()
.filter(|v| v.side == ActivitySide::Bottom)
.count();
assert_eq!(bot_count, 3, "all bottom items visible");
assert_eq!(
top_count, 3,
"top truncated to fit above bottom reserved area"
);
}
#[test]
fn activity_bar_layout_pixel_units() {
let bar = ActivityBar {
id: WidgetId::new("a"),
top_items: (0..3)
.map(|i| make_activity_item(&format!("top:{i}"), 'T'))
.collect(),
bottom_items: vec![make_activity_item("activity:settings", 'G')],
active_accent: None,
selection_bg: None,
is_keyboard_focused: false,
};
let layout = bar.layout(48.0, 200.0, 48.0);
let top0 = layout
.visible_items
.iter()
.find(|v| v.side == ActivitySide::Top && v.item_idx == 0)
.unwrap();
assert_eq!(top0.bounds.y, 0.0);
assert_eq!(top0.bounds.height, 48.0);
let bot0 = layout
.visible_items
.iter()
.find(|v| v.side == ActivitySide::Bottom)
.unwrap();
assert_eq!(bot0.bounds.y, 152.0);
}
}