use std::path::{Path, PathBuf};
use chrono::{DateTime, Local};
use serde::Deserialize;
use serde::de::{Deserializer, Error as DeError};
use crate::icon::BuiltinIcon;
use crate::render::{Bounds, FG, RenderContext};
pub mod backlight;
pub mod battery;
pub mod bluetooth;
pub mod clock;
pub mod hypridle;
pub mod network;
pub mod notifications;
pub mod power;
pub mod power_profiles;
pub mod system;
pub mod title;
pub mod tray;
pub mod updates;
pub mod volume;
pub mod workspaces;
pub use backlight::{Backlight, BacklightWidget};
pub use battery::{Battery, BatteryState, BatteryWidget};
pub use bluetooth::{Bluetooth, BluetoothState, BluetoothWidget};
pub use clock::ClockWidget;
pub use hypridle::{Hypridle, HypridleWidget};
pub use network::{Network, NetworkState, NetworkWidget};
pub use notifications::{Notifications, NotificationsWidget};
pub use power::PowerWidget;
pub use power_profiles::{PowerProfile, PowerProfilesState, PowerProfilesWidget};
pub use system::{SystemStats, SystemWidget};
pub use title::{ActiveWindow, TitleWidget};
pub use tray::{
TrayIcon, TrayItem, TrayMenu, TrayMenuItem, TrayMenuMode, TrayMenuToggle, TrayMenuToggleKind,
TrayMenuToggleState, TrayState, TrayStatus, TrayWidget,
};
pub use updates::{PackageUpdate, PackageUpdates, UpdatesWidget};
pub use volume::{DeviceKind, Volume, VolumeWidget};
pub use workspaces::{WorkspaceWidget, Workspaces};
const ALERT_BACKGROUND: (u8, u8, u8, u8) = (0xBF, 0x61, 0x6A, 0xFF);
const ALERT_FOREGROUND: (u8, u8, u8, u8) = (0xFF, 0xFF, 0xFF, 0xFF);
const DEFAULT_RADIUS: u32 = 8;
const DEFAULT_PADDING: u32 = 8;
const DEFAULT_BORDER_WIDTH: u32 = 1;
const DEFAULT_WARN_THRESHOLD: u32 = 20;
pub(crate) const CHARGING_FOREGROUND: (u8, u8, u8, u8) = (0x5F, 0xF5, 0xA0, 0xFF);
#[non_exhaustive]
#[derive(Debug, Clone)]
pub enum Msg {
Tick(DateTime<Local>),
Workspaces(Workspaces),
Battery(Option<Battery>),
Backlight(Vec<Backlight>),
System(SystemStats),
Network(Option<Network>),
Bluetooth(Bluetooth),
Tray(TrayState),
TrayMenu(TrayMenu),
TrayMenuUnavailable(String),
ActiveWindow {
monitor: String,
window: Option<ActiveWindow>,
},
Volume(Option<Volume>),
Notifications(Option<Notifications>),
PowerProfiles(Option<PowerProfilesState>),
Updates(Option<PackageUpdates>),
Hypridle(Hypridle),
}
impl Msg {
pub fn tick_now() -> Self {
Msg::Tick(Local::now())
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
SwitchWorkspace(i32),
ActivateTrayItem {
key: String,
x: i32,
y: i32,
},
OpenTrayMenu {
key: String,
x: i32,
y: i32,
},
ActivateTrayMenuItem {
key: String,
id: i32,
},
RunProgram(LaunchSpec),
ToggleNotificationPanel,
ToggleNotificationsDnd,
AdjustBacklight {
device: String,
direction: ScrollDirection,
step: f64,
},
SetPowerProfile(String),
SetHypridle(bool),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollDirection {
Increase,
Decrease,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchSpec {
program: PathBuf,
args: Vec<String>,
}
impl LaunchSpec {
pub fn program_only(program: impl Into<PathBuf>) -> Self {
Self {
program: program.into(),
args: Vec::new(),
}
}
pub fn with_args(program: impl Into<PathBuf>, args: impl IntoIterator<Item = String>) -> Self {
Self {
program: program.into(),
args: args.into_iter().collect(),
}
}
pub fn parse(s: &str) -> Result<Self, String> {
let mut tokens = s.split_whitespace();
let Some(program) = tokens.next() else {
return Err("on-click is empty".to_string());
};
Ok(Self {
program: PathBuf::from(program),
args: tokens.map(str::to_owned).collect(),
})
}
pub fn from_argv(argv: Vec<String>) -> Result<Self, String> {
let mut iter = argv.into_iter();
let Some(program) = iter.next() else {
return Err("on-click list is empty".to_string());
};
if program.is_empty() {
return Err("on-click program is empty".to_string());
}
Ok(Self {
program: PathBuf::from(program),
args: iter.collect(),
})
}
pub fn program(&self) -> &Path {
&self.program
}
pub fn args(&self) -> &[String] {
&self.args
}
pub fn display(&self) -> String {
if self.args.is_empty() {
self.program.display().to_string()
} else {
let mut out = self.program.display().to_string();
for arg in &self.args {
out.push(' ');
out.push_str(arg);
}
out
}
}
}
impl From<PathBuf> for LaunchSpec {
fn from(program: PathBuf) -> Self {
Self::program_only(program)
}
}
impl From<&str> for LaunchSpec {
fn from(s: &str) -> Self {
Self::parse(s).unwrap_or_else(|_| Self::program_only(PathBuf::from(s)))
}
}
impl From<String> for LaunchSpec {
fn from(s: String) -> Self {
Self::from(s.as_str())
}
}
impl<'de> Deserialize<'de> for LaunchSpec {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
String(String),
List(Vec<String>),
}
match Raw::deserialize(deserializer)? {
Raw::String(s) => Self::parse(&s).map_err(DeError::custom),
Raw::List(list) => Self::from_argv(list).map_err(DeError::custom),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClickButton {
Left,
Right,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tooltip {
pub text: String,
pub bounds: Bounds,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Hover {
pub clickable: bool,
pub tooltip: Option<Tooltip>,
pub widget: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum IconSetting {
#[default]
Default,
None,
Custom(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum ResolvedIcon {
None,
Builtin(BuiltinIcon),
Text(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StateColors {
pub background: Option<(u8, u8, u8, u8)>,
pub foreground: (u8, u8, u8, u8),
pub border: Option<(u8, u8, u8, u8)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct HoverColors {
pub background: Option<(u8, u8, u8, u8)>,
pub foreground: Option<(u8, u8, u8, u8)>,
pub border: Option<(u8, u8, u8, u8)>,
}
impl HoverColors {
pub fn apply(self, colors: StateColors, bar_background: (u8, u8, u8, u8)) -> StateColors {
let mix =
|base: u8, ink: u8| ((u32::from(base) * 90 + u32::from(ink) * 10 + 50) / 100) as u8;
let (br, bg, bb, ba) = colors.background.unwrap_or(bar_background);
let (fr, fg, fb, fa) = colors.foreground;
StateColors {
background: Some(self.background.unwrap_or((
mix(br, fr),
mix(bg, fg),
mix(bb, fb),
mix(ba, fa),
))),
foreground: self.foreground.unwrap_or(colors.foreground),
border: self.border.or(colors.border),
}
}
}
fn paint_colors(ctx: &RenderContext, style: &WidgetStyle, colors: StateColors) -> StateColors {
if ctx.hovered() {
style.hover.apply(colors, ctx.settings().background)
} else {
colors
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct WidgetStyle {
pub background: Option<(u8, u8, u8, u8)>,
pub foreground: (u8, u8, u8, u8),
pub accent: (u8, u8, u8, u8),
pub border: Option<(u8, u8, u8, u8)>,
pub border_width: u32,
pub radius: u32,
pub padding: u32,
pub icon: IconSetting,
pub icon_size: Option<u32>,
pub warn_threshold: u32,
pub warn: StateColors,
pub attention: StateColors,
pub charging: StateColors,
pub hover: HoverColors,
}
impl Default for WidgetStyle {
fn default() -> Self {
Self {
background: None,
foreground: FG,
accent: FG,
border: None,
border_width: DEFAULT_BORDER_WIDTH,
radius: DEFAULT_RADIUS,
padding: DEFAULT_PADDING,
icon: IconSetting::Default,
icon_size: None,
warn_threshold: DEFAULT_WARN_THRESHOLD,
warn: StateColors {
background: Some(ALERT_BACKGROUND),
foreground: ALERT_FOREGROUND,
border: None,
},
attention: StateColors {
background: Some(ALERT_BACKGROUND),
foreground: ALERT_FOREGROUND,
border: None,
},
charging: StateColors {
background: None,
foreground: CHARGING_FOREGROUND,
border: None,
},
hover: HoverColors::default(),
}
}
}
impl WidgetStyle {
pub fn glyph<'a>(&'a self, default: &'a str) -> &'a str {
match &self.icon {
IconSetting::Default => default,
IconSetting::None => "",
IconSetting::Custom(s) => s,
}
}
pub fn resolve_icon(&self, default: BuiltinIcon) -> ResolvedIcon {
match &self.icon {
IconSetting::Default => ResolvedIcon::Builtin(default),
IconSetting::None => ResolvedIcon::None,
IconSetting::Custom(s) => ResolvedIcon::Text(s.clone()),
}
}
pub fn base_colors(&self) -> StateColors {
StateColors {
background: self.background,
foreground: self.foreground,
border: self.border,
}
}
}
pub fn validate_placeholders(format: &str, allowed: &[&str]) -> Result<(), String> {
let mut rest = format;
while !rest.is_empty() {
let next_open = rest.find('{');
let next_close = rest.find('}');
if next_close.is_some_and(|close| next_open.is_none_or(|open| close < open)) {
return Err("contains an unmatched `}`".to_string());
}
let Some(open) = next_open else {
break;
};
rest = &rest[open + 1..];
let Some(close) = rest.find('}') else {
return Err("contains an unmatched `{`".to_string());
};
let placeholder = &rest[..close];
if !allowed.contains(&placeholder) {
return Err(format!(
"contains unsupported placeholder `{{{placeholder}}}`"
));
}
rest = &rest[close + 1..];
}
Ok(())
}
pub(crate) fn expand(format: &str, values: &[(&str, &str)]) -> String {
let mut out = String::with_capacity(format.len());
let mut rest = format;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let tail = &rest[open..];
let value = tail.find('}').and_then(|close| {
let name = &tail[1..close];
values
.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| (*value, close))
});
match value {
Some((value, close)) => {
out.push_str(value);
rest = &tail[close + 1..];
}
None => {
out.push('{');
rest = &tail[1..];
}
}
}
out.push_str(rest);
out
}
pub(crate) fn glyph_label(glyph: &str, label: &str) -> String {
match (glyph.is_empty(), label.is_empty()) {
(true, _) => label.to_string(),
(false, true) => glyph.to_string(),
(false, false) => format!("{glyph} {label}"),
}
}
pub(crate) fn measure_text_pill(ctx: &mut RenderContext, style: &WidgetStyle, text: &str) -> u32 {
if text.is_empty() {
return 0;
}
let pad = style.padding * ctx.scale_factor();
ctx.measure_text(text) + 2 * pad
}
pub(crate) fn draw_text_pill(
ctx: &mut RenderContext,
style: &WidgetStyle,
bounds: Bounds,
text: &str,
colors: StateColors,
) {
if text.is_empty() || bounds.width == 0 || bounds.height == 0 {
return;
}
let colors = paint_colors(ctx, style, colors);
let scale = ctx.scale_factor();
if let Some(bg) = colors.background {
ctx.fill_rounded_rect(bounds, bg, (style.radius * scale) as f32);
}
if let Some(border) = colors.border.or(style.border) {
ctx.stroke_rounded_rect(
bounds,
border,
(style.radius * scale) as f32,
(style.border_width * scale) as f32,
);
}
let pad = style.padding * scale;
let inner = Bounds::new(
bounds.x + pad,
bounds.y,
bounds.width.saturating_sub(2 * pad),
bounds.height,
);
ctx.draw_text(text, inner, colors.foreground);
}
pub(crate) fn draw_icon_pill(
ctx: &mut RenderContext,
style: &WidgetStyle,
bounds: Bounds,
icon: &str,
colors: StateColors,
) {
if icon.is_empty() || bounds.width == 0 || bounds.height == 0 {
return;
}
let colors = paint_colors(ctx, style, colors);
let scale = ctx.scale_factor();
if let Some(bg) = colors.background {
ctx.fill_rounded_rect(bounds, bg, (style.radius * scale) as f32);
}
if let Some(border) = colors.border.or(style.border) {
ctx.stroke_rounded_rect(
bounds,
border,
(style.radius * scale) as f32,
(style.border_width * scale) as f32,
);
}
ctx.draw_text_centered(icon, bounds, colors.foreground);
}
struct ContentBox {
text: Option<String>,
width: u32,
}
fn icon_gap(edge: u32) -> u32 {
(edge / 4).max(1)
}
fn icon_boxes(ctx: &mut RenderContext, edge: u32, template: &str) -> Vec<ContentBox> {
let mut boxes = Vec::new();
let mut rest = template;
loop {
match rest.find("{icon}") {
Some(idx) => {
let text = rest[..idx].trim();
if !text.is_empty() {
let width = ctx.measure_text(text);
boxes.push(ContentBox {
text: Some(text.to_string()),
width,
});
}
boxes.push(ContentBox {
text: None,
width: edge,
});
rest = &rest[idx + "{icon}".len()..];
}
None => {
let text = rest.trim();
if !text.is_empty() {
let width = ctx.measure_text(text);
boxes.push(ContentBox {
text: Some(text.to_string()),
width,
});
}
break;
}
}
}
boxes
}
fn icon_as_text(template: &str, repl: Option<&str>) -> String {
let repl = repl.map(str::trim).filter(|s| !s.is_empty());
let mut parts: Vec<&str> = Vec::new();
let mut rest = template;
loop {
match rest.find("{icon}") {
Some(idx) => {
let before = rest[..idx].trim();
if !before.is_empty() {
parts.push(before);
}
if let Some(repl) = repl {
parts.push(repl);
}
rest = &rest[idx + "{icon}".len()..];
}
None => {
let last = rest.trim();
if !last.is_empty() {
parts.push(last);
}
break;
}
}
}
parts.join(" ")
}
fn is_icon_only(template: &str) -> bool {
template.contains("{icon}") && template.replace("{icon}", " ").trim().is_empty()
}
pub(crate) fn measure_icon_content(
ctx: &mut RenderContext,
style: &WidgetStyle,
icon: &ResolvedIcon,
template: &str,
) -> u32 {
match icon {
ResolvedIcon::Builtin(_) => {
let edge = builtin_edge(ctx, style);
let boxes = icon_boxes(ctx, edge, template);
measure_boxes(ctx, style, &boxes, icon_gap(edge))
}
ResolvedIcon::Text(glyph) if style.icon_size.is_some() => {
let (boxes, gap, _) = sized_glyph_boxes(ctx, style, glyph, template);
measure_boxes(ctx, style, &boxes, gap)
}
ResolvedIcon::None => measure_text_pill(ctx, style, &icon_as_text(template, None)),
ResolvedIcon::Text(s) => measure_text_pill(ctx, style, &icon_as_text(template, Some(s))),
}
}
fn builtin_edge(ctx: &RenderContext, style: &WidgetStyle) -> u32 {
style
.icon_size
.map_or_else(|| ctx.icon_edge(), |size| size * ctx.scale_factor())
}
fn sized_glyph_boxes(
ctx: &mut RenderContext,
style: &WidgetStyle,
glyph: &str,
template: &str,
) -> (Vec<ContentBox>, u32, Option<f32>) {
let size = style
.icon_size
.map(|size| (size * ctx.scale_factor()) as f32);
let width = ctx.measure_text_sized(glyph.trim(), size);
let gap = icon_gap(size.map_or_else(|| ctx.icon_edge(), |size| size as u32));
(icon_boxes(ctx, width, template), gap, size)
}
fn measure_boxes(ctx: &RenderContext, style: &WidgetStyle, boxes: &[ContentBox], gap: u32) -> u32 {
if boxes.is_empty() {
return 0;
}
let content: u32 = boxes.iter().map(|b| b.width).sum::<u32>() + gap * (boxes.len() as u32 - 1);
content + 2 * style.padding * ctx.scale_factor()
}
pub(crate) fn draw_icon_content(
ctx: &mut RenderContext,
style: &WidgetStyle,
bounds: Bounds,
icon: &ResolvedIcon,
template: &str,
colors: StateColors,
) {
match icon {
ResolvedIcon::Builtin(builtin) => {
draw_builtin_content(ctx, style, bounds, &[*builtin], template, colors);
}
ResolvedIcon::Text(glyph) if style.icon_size.is_some() => {
if bounds.width == 0 || bounds.height == 0 {
return;
}
let (boxes, gap, size) = sized_glyph_boxes(ctx, style, glyph, template);
let glyph = glyph.trim();
draw_boxes(
ctx,
style,
bounds,
&boxes,
gap,
colors,
|ctx, _, slot, color| {
ctx.draw_text_centered_sized(glyph, slot, color, size);
},
);
}
ResolvedIcon::None => {
draw_text_pill(ctx, style, bounds, &icon_as_text(template, None), colors);
}
ResolvedIcon::Text(s) => {
let text = icon_as_text(template, Some(s));
if is_icon_only(template) {
draw_icon_pill(ctx, style, bounds, &text, colors);
} else {
draw_text_pill(ctx, style, bounds, &text, colors);
}
}
}
}
pub(crate) fn draw_builtin_icons(
ctx: &mut RenderContext,
style: &WidgetStyle,
bounds: Bounds,
icons: &[BuiltinIcon],
template: &str,
colors: StateColors,
) {
draw_builtin_content(ctx, style, bounds, icons, template, colors);
}
fn draw_builtin_content(
ctx: &mut RenderContext,
style: &WidgetStyle,
bounds: Bounds,
icons: &[BuiltinIcon],
template: &str,
colors: StateColors,
) {
if bounds.width == 0 || bounds.height == 0 || icons.is_empty() {
return;
}
let edge = builtin_edge(ctx, style);
let boxes = icon_boxes(ctx, edge, template);
draw_boxes(
ctx,
style,
bounds,
&boxes,
icon_gap(edge),
colors,
|ctx, slot_index, slot, color| {
let builtin = icons[slot_index.min(icons.len() - 1)];
let iy = slot.y + slot.height.saturating_sub(edge) / 2;
ctx.draw_builtin_icon(builtin, Bounds::new(slot.x, iy, edge, edge), color);
},
);
}
fn draw_boxes(
ctx: &mut RenderContext,
style: &WidgetStyle,
bounds: Bounds,
boxes: &[ContentBox],
gap: u32,
colors: StateColors,
mut draw_icon: impl FnMut(&mut RenderContext, usize, Bounds, (u8, u8, u8, u8)),
) {
if boxes.is_empty() {
return;
}
let colors = paint_colors(ctx, style, colors);
let scale = ctx.scale_factor();
if let Some(bg) = colors.background {
ctx.fill_rounded_rect(bounds, bg, (style.radius * scale) as f32);
}
if let Some(border) = colors.border.or(style.border) {
ctx.stroke_rounded_rect(
bounds,
border,
(style.radius * scale) as f32,
(style.border_width * scale) as f32,
);
}
let pad = style.padding * scale;
let mut x = bounds.x + pad;
let mut icon_slot = 0;
for (index, content) in boxes.iter().enumerate() {
if index > 0 {
x += gap;
}
match &content.text {
None => {
let slot = Bounds::new(x, bounds.y, content.width, bounds.height);
draw_icon(ctx, icon_slot, slot, colors.foreground);
icon_slot += 1;
}
Some(text) => {
ctx.draw_text(
text,
Bounds::new(x, bounds.y, content.width, bounds.height),
colors.foreground,
);
}
}
x += content.width;
}
}
pub(crate) fn draw_centered(
ctx: &mut RenderContext,
text: &str,
bounds: Bounds,
color: (u8, u8, u8, u8),
) {
if text.is_empty() || bounds.width == 0 || bounds.height == 0 {
return;
}
let pad = bounds.width.saturating_sub(ctx.measure_text(text)) / 2;
let inner = Bounds::new(
bounds.x + pad,
bounds.y,
bounds.width.saturating_sub(pad),
bounds.height,
);
ctx.draw_text(text, inner, color);
}
pub trait Widget {
fn update(&mut self, msg: &Msg) -> bool;
fn draw(&self, ctx: &mut RenderContext);
fn measure(&self, _ctx: &mut RenderContext, _height: u32) -> u32 {
0
}
fn bounds(&self) -> Bounds;
fn set_bounds(&mut self, bounds: Bounds);
fn on_click(&self, _px: u32, _py: u32, _button: ClickButton) -> Option<Command> {
None
}
fn on_scroll(&self, _px: u32, _py: u32, _direction: ScrollDirection) -> Option<Command> {
None
}
fn tooltip_at(&self, _px: u32, _py: u32) -> Option<Tooltip> {
None
}
}
pub struct Dashboard {
left: Vec<Box<dyn Widget>>,
center: Vec<Box<dyn Widget>>,
right: Vec<Box<dyn Widget>>,
margin: u32,
gap: u32,
changed: Vec<bool>,
painted: Option<Vec<Bounds>>,
hovered: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Damage {
Full,
Region(Bounds),
}
impl Damage {
pub fn union(self, other: Self) -> Self {
match (self, other) {
(Self::Region(a), Self::Region(b)) => Self::Region(a.union(&b)),
_ => Self::Full,
}
}
}
impl Dashboard {
pub fn new(widgets: Vec<Box<dyn Widget>>) -> Self {
Self::with_zones(widgets, Vec::new(), Vec::new())
}
pub fn with_zones(
left: Vec<Box<dyn Widget>>,
center: Vec<Box<dyn Widget>>,
right: Vec<Box<dyn Widget>>,
) -> Self {
let widgets = left.len() + center.len() + right.len();
Self {
left,
center,
right,
margin: 0,
gap: 0,
changed: vec![false; widgets],
painted: None,
hovered: None,
}
}
pub fn with_spacing(mut self, margin: u32, gap: u32) -> Self {
self.margin = margin;
self.gap = gap;
self
}
#[hotpath::measure(impl_type = "Dashboard")]
pub fn update(&mut self, msg: &Msg) -> bool {
let mut dirty = false;
for (widget, changed) in self
.left
.iter_mut()
.chain(&mut self.center)
.chain(&mut self.right)
.zip(&mut self.changed)
{
let updated = widget.update(msg);
*changed |= updated;
dirty |= updated;
}
dirty
}
pub fn take_damage(&mut self) -> Damage {
let bounds: Vec<Bounds> = self.widgets().map(|widget| widget.bounds()).collect();
let region = if self.painted.as_ref() == Some(&bounds) {
bounds
.iter()
.zip(&self.changed)
.filter(|(_, changed)| **changed)
.map(|(bounds, _)| *bounds)
.reduce(|damage, bounds| damage.union(&bounds))
} else {
None
};
self.painted = Some(bounds);
self.changed.fill(false);
region.map_or(Damage::Full, Damage::Region)
}
pub fn invalidate(&mut self) {
self.painted = None;
}
pub fn set_hovered(&mut self, index: Option<usize>) -> bool {
let index = index.filter(|&i| i < self.changed.len());
if index == self.hovered {
return false;
}
for i in [self.hovered, index].into_iter().flatten() {
self.changed[i] = true;
}
self.hovered = index;
true
}
fn widgets(&self) -> impl Iterator<Item = &Box<dyn Widget>> {
self.left.iter().chain(&self.center).chain(&self.right)
}
#[hotpath::measure(impl_type = "Dashboard")]
pub fn layout(&mut self, ctx: &mut RenderContext, width: u32, height: u32) {
let scale = ctx.scale_factor();
let margin = self.margin * scale;
let gap = self.gap * scale;
let top = margin;
let inner_h = height.saturating_sub(2 * margin);
let left_w = measure_zone(&self.left, ctx, inner_h);
let center_w = measure_zone(&self.center, ctx, inner_h);
let right_w = measure_zone(&self.right, ctx, inner_h);
let left_total = cluster_width(&left_w, gap);
let center_total = cluster_width(¢er_w, gap);
let right_total = cluster_width(&right_w, gap);
let left_start = margin;
place_cluster(&mut self.left, &left_w, left_start, gap, top, inner_h);
let right_edge = width.saturating_sub(margin);
let right_start = right_edge.saturating_sub(right_total);
place_cluster(&mut self.right, &right_w, right_start, gap, top, inner_h);
let lo = if left_total > 0 {
left_start + left_total + gap
} else {
left_start
};
let hi = if right_total > 0 {
right_start.saturating_sub(gap)
} else {
right_edge
};
if center_total > 0 && hi.saturating_sub(lo) >= center_total {
let ideal = width.saturating_sub(center_total) / 2;
let max_start = hi - center_total;
let start = ideal.clamp(lo, max_start);
place_cluster(&mut self.center, ¢er_w, start, gap, top, inner_h);
} else {
let zeros = vec![0u32; center_w.len()];
place_cluster(&mut self.center, &zeros, lo, gap, top, inner_h);
}
}
#[hotpath::measure(impl_type = "Dashboard")]
pub fn draw(&self, ctx: &mut RenderContext) {
ctx.fill_background();
for (index, widget) in self.widgets().enumerate() {
ctx.set_hovered(self.hovered == Some(index));
widget.draw(ctx);
}
ctx.set_hovered(false);
}
#[hotpath::measure(impl_type = "Dashboard")]
pub fn draw_damage(&self, ctx: &mut RenderContext, damage: Damage) -> Damage {
let Damage::Region(mut region) = damage else {
self.draw(ctx);
return Damage::Full;
};
loop {
let grown = self
.widgets()
.map(|widget| widget.bounds())
.filter(|bounds| bounds.intersects(®ion))
.fold(region, |region, bounds| region.union(&bounds));
if grown == region {
break;
}
region = grown;
}
ctx.fill_background_in(region);
for (index, widget) in self.widgets().enumerate() {
if widget.bounds().intersects(®ion) {
ctx.set_hovered(self.hovered == Some(index));
widget.draw(ctx);
}
}
ctx.set_hovered(false);
Damage::Region(region)
}
#[hotpath::measure(impl_type = "Dashboard")]
pub fn on_click(&self, px: u32, py: u32, button: ClickButton) -> Option<Command> {
self.left
.iter()
.chain(&self.center)
.chain(&self.right)
.find_map(|widget| widget.on_click(px, py, button))
}
#[hotpath::measure(impl_type = "Dashboard")]
pub fn hover_at(&self, px: u32, py: u32) -> Hover {
let Some((index, widget)) = self
.widgets()
.enumerate()
.find(|(_, widget)| widget.bounds().contains(px, py))
else {
return Hover::default();
};
let clickable = widget.on_click(px, py, ClickButton::Left).is_some()
|| widget.on_click(px, py, ClickButton::Right).is_some();
let tooltip = widget.tooltip_at(px, py);
Hover {
widget: (clickable || tooltip.is_some()).then_some(index),
clickable,
tooltip,
}
}
#[hotpath::measure(impl_type = "Dashboard")]
pub fn on_scroll(&self, px: u32, py: u32, direction: ScrollDirection) -> Option<Command> {
self.left
.iter()
.chain(&self.center)
.chain(&self.right)
.find_map(|widget| widget.on_scroll(px, py, direction))
}
}
#[hotpath::measure]
fn measure_zone(widgets: &[Box<dyn Widget>], ctx: &mut RenderContext, height: u32) -> Vec<u32> {
let mut widths = Vec::with_capacity(widgets.len());
for widget in widgets {
widths.push(widget.measure(ctx, height));
}
widths
}
fn cluster_width(widths: &[u32], gap: u32) -> u32 {
let count = widths.iter().filter(|&&w| w > 0).count() as u32;
let sum: u32 = widths.iter().sum();
sum + gap * count.saturating_sub(1)
}
fn place_cluster(
widgets: &mut [Box<dyn Widget>],
widths: &[u32],
mut x: u32,
gap: u32,
top: u32,
height: u32,
) {
let mut placed = false;
for (widget, &w) in widgets.iter_mut().zip(widths) {
if w == 0 {
widget.set_bounds(Bounds::new(x, top, 0, height));
continue;
}
if placed {
x += gap;
}
widget.set_bounds(Bounds::new(x, top, w, height));
x += w;
placed = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn at(h: u32, m: u32, s: u32) -> Msg {
Msg::Tick(Local.with_ymd_and_hms(2026, 6, 27, h, m, s).unwrap())
}
fn one_workspace(id: i32) -> Box<dyn Widget> {
let mut ws = WorkspaceWidget::new(Bounds::new(0, 0, 1, 1));
ws.update(&Msg::Workspaces(Workspaces::new([id], id)));
Box::new(ws)
}
#[test]
fn update_is_dirty_only_when_a_widget_changes() {
let mut dash = Dashboard::new(vec![Box::new(ClockWidget::new(Bounds::new(0, 0, 320, 32)))]);
assert!(dash.update(&at(12, 0, 0)));
assert!(!dash.update(&at(12, 0, 30)));
assert!(dash.update(&at(12, 1, 0)));
}
#[test]
fn validate_placeholders_accepts_only_allowed_names() {
assert_eq!(
validate_placeholders("{icon}{percent}%", &["icon", "percent"]),
Ok(())
);
assert_eq!(
validate_placeholders("{icon} {nope}", &["icon"]),
Err("contains unsupported placeholder `{nope}`".to_string())
);
}
#[test]
fn validate_placeholders_rejects_unmatched_braces() {
assert_eq!(
validate_placeholders("{icon", &["icon"]),
Err("contains an unmatched `{`".to_string())
);
assert_eq!(
validate_placeholders("icon}", &["icon"]),
Err("contains an unmatched `}`".to_string())
);
}
#[test]
fn expand_fills_values_and_keeps_icon_marker() {
assert_eq!(
expand(
"{icon}{percent}% {state}",
&[("percent", "42"), ("state", "on")]
),
"{icon}42% on"
);
}
#[test]
fn empty_dashboard_never_reports_dirty() {
let mut dash = Dashboard::new(vec![]);
assert!(!dash.update(&at(12, 0, 0)));
}
#[test]
fn layout_packs_left_and_right_zones_to_their_edges() {
let mut dash =
Dashboard::with_zones(vec![one_workspace(1)], vec![], vec![one_workspace(2)]);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
assert_eq!(dash.left[0].bounds(), Bounds::new(0, 0, 32, 32));
assert_eq!(dash.right[0].bounds(), Bounds::new(168, 0, 32, 32));
}
#[test]
fn layout_centers_the_center_zone_between_the_edges() {
let mut dash = Dashboard::with_zones(vec![], vec![one_workspace(1)], vec![]);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
assert_eq!(dash.center[0].bounds(), Bounds::new(84, 0, 32, 32));
}
#[test]
fn layout_reserves_the_gap_between_pills_in_a_zone() {
let mut dash =
Dashboard::with_zones(vec![one_workspace(1), one_workspace(2)], vec![], vec![])
.with_spacing(0, 10);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
assert_eq!(dash.left[0].bounds(), Bounds::new(0, 0, 32, 32));
assert_eq!(dash.left[1].bounds(), Bounds::new(42, 0, 32, 32));
}
#[test]
fn layout_insets_the_row_by_the_bar_margin() {
let mut dash =
Dashboard::with_zones(vec![one_workspace(1)], vec![], vec![]).with_spacing(4, 0);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
assert_eq!(dash.left[0].bounds(), Bounds::new(4, 4, 24, 24));
}
#[test]
fn an_unmeasured_widget_reserves_no_slot() {
let clock = ClockWidget::new(Bounds::new(0, 0, 1, 1));
let mut dash =
Dashboard::with_zones(vec![Box::new(clock), one_workspace(1)], vec![], vec![]);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
assert_eq!(dash.left[0].bounds().width, 0);
assert_eq!(dash.left[1].bounds(), Bounds::new(0, 0, 32, 32));
}
#[test]
fn on_click_routes_to_the_zone_that_owns_the_pixel() {
let mut dash = Dashboard::with_zones(
vec![],
vec![Box::new(ClockWidget::new(Bounds::new(0, 0, 1, 1)))],
vec![one_workspace(1)],
);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
assert_eq!(
dash.on_click(180, 16, ClickButton::Left),
Some(Command::SwitchWorkspace(1))
);
assert_eq!(dash.on_click(100, 16, ClickButton::Left), None);
}
#[test]
fn on_click_threads_the_button_through_to_the_widget() {
let mut dash = Dashboard::with_zones(vec![], vec![], vec![one_workspace(1)]);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
assert_eq!(
dash.on_click(180, 16, ClickButton::Left),
Some(Command::SwitchWorkspace(1))
);
assert_eq!(dash.on_click(180, 16, ClickButton::Right), None);
}
#[test]
fn clickable_regions_match_widgets_with_click_actions() {
let mut dash = Dashboard::with_zones(vec![], vec![], vec![one_workspace(1)]);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
assert!(dash.hover_at(180, 16).clickable);
assert!(!dash.hover_at(100, 16).clickable);
}
fn hoverable_dashboard() -> Dashboard {
let mut workspaces = WorkspaceWidget::new(Bounds::new(0, 0, 1, 1));
workspaces.update(&Msg::Workspaces(Workspaces::new([1, 2], 1)));
let mut dash = Dashboard::with_zones(
vec![Box::new(workspaces)],
vec![Box::new(ClockWidget::new(Bounds::new(0, 0, 1, 1)))],
vec![Box::new(HypridleWidget::new(Bounds::new(0, 0, 1, 1)))],
);
dash.update(&at(12, 0, 0));
dash.update(&Msg::Hypridle(Hypridle::new(true)));
dash.layout(&mut RenderContext::new(400, 32), 400, 32);
dash
}
#[test]
fn hovering_a_widget_reports_its_click_action_and_tooltip_together() {
let dash = hoverable_dashboard();
let hypridle = dash.right[0].bounds();
let (px, py) = (
hypridle.x + hypridle.width / 2,
hypridle.y + hypridle.height / 2,
);
let hover = dash.hover_at(px, py);
assert!(hover.clickable);
assert_eq!(
hover.tooltip,
Some(Tooltip {
text: "Hypridle active".to_owned(),
bounds: hypridle,
})
);
}
#[test]
fn hovering_empty_bar_space_reports_nothing() {
let dash = hoverable_dashboard();
let gap = dash.left[0].bounds().x + dash.left[0].bounds().width + 1;
assert!(
!dash.center[0].bounds().contains(gap, 16),
"probe is in a gap"
);
assert_eq!(dash.hover_at(gap, 16), Hover::default());
assert_eq!(Dashboard::new(vec![]).hover_at(0, 0), Hover::default());
}
#[test]
fn hover_agrees_with_asking_every_widget_at_every_pixel() {
let dash = hoverable_dashboard();
for py in 0..32 {
for px in 0..400 {
let clickable = dash.on_click(px, py, ClickButton::Left).is_some()
|| dash.on_click(px, py, ClickButton::Right).is_some();
let tooltip = dash.widgets().find_map(|widget| widget.tooltip_at(px, py));
let widget = (clickable || tooltip.is_some())
.then(|| dash.widgets().position(|w| w.bounds().contains(px, py)))
.flatten();
let exhaustive = Hover {
clickable,
tooltip,
widget,
};
assert_eq!(dash.hover_at(px, py), exhaustive, "at ({px}, {py})");
}
}
}
#[test]
fn hover_names_only_interactive_widgets() {
let dash = hoverable_dashboard();
let center = |b: Bounds| (b.x + b.width / 2, b.y + b.height / 2);
let (px, py) = center(dash.right[0].bounds());
assert_eq!(dash.hover_at(px, py).widget, Some(2));
let (px, py) = center(dash.center[0].bounds());
assert_eq!(dash.hover_at(px, py).widget, None);
}
#[test]
fn changing_the_hovered_widget_damages_only_the_old_and_new_slots() {
let mut dash = hoverable_dashboard();
let mut ctx = RenderContext::new(400, 32);
dash.layout(&mut ctx, 400, 32);
let _ = dash.take_damage();
assert!(!dash.set_hovered(None));
assert!(dash.set_hovered(Some(2)));
assert_eq!(dash.take_damage(), Damage::Region(dash.right[0].bounds()));
assert!(!dash.set_hovered(Some(2)));
assert!(dash.set_hovered(Some(0)));
assert_eq!(
dash.take_damage(),
Damage::Region(dash.left[0].bounds().union(&dash.right[0].bounds()))
);
assert!(dash.set_hovered(None));
assert_eq!(dash.take_damage(), Damage::Region(dash.left[0].bounds()));
assert!(!dash.set_hovered(Some(99)));
}
#[test]
fn a_hovered_pill_paints_its_hover_colors() {
let style = WidgetStyle {
hover: HoverColors {
background: Some((0xFF, 0, 0, 0xFF)),
..HoverColors::default()
},
..WidgetStyle::default()
};
let mut idle = HypridleWidget::new(Bounds::new(0, 0, 1, 1)).with_style(style);
idle.update(&Msg::Hypridle(Hypridle::new(true)));
let mut dash = Dashboard::new(vec![Box::new(idle)]);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
let slot = dash.left[0].bounds();
let probe = |ctx: &RenderContext| {
let i = ((slot.y + slot.height / 2) * 200 + slot.x + 2) as usize * 4;
ctx.pixels()[i..i + 4].to_vec()
};
dash.draw(&mut ctx);
assert_ne!(probe(&ctx), [0xFF, 0, 0, 0xFF]);
dash.set_hovered(Some(0));
dash.draw(&mut ctx);
assert_eq!(probe(&ctx), [0xFF, 0, 0, 0xFF]);
dash.set_hovered(None);
dash.draw(&mut ctx);
assert_ne!(probe(&ctx), [0xFF, 0, 0, 0xFF]);
}
#[test]
fn default_hover_mixes_a_tenth_of_the_foreground_into_the_background() {
let hover = HoverColors::default();
let base = StateColors {
background: Some((0, 0, 0, 0xFF)),
foreground: (200, 100, 50, 0xFF),
border: None,
};
let colors = hover.apply(base, (10, 10, 10, 0xFF));
assert_eq!(colors.background, Some((20, 10, 5, 0xFF)));
assert_eq!(colors.foreground, base.foreground);
let flat = hover.apply(
StateColors {
background: None,
..base
},
(0, 0, 0, 0),
);
assert_eq!(flat.background, Some((20, 10, 5, 26)));
let custom = HoverColors {
background: Some((1, 2, 3, 4)),
foreground: Some((5, 6, 7, 8)),
border: Some((9, 9, 9, 9)),
}
.apply(base, (0, 0, 0, 0));
assert_eq!(
custom,
StateColors {
background: Some((1, 2, 3, 4)),
foreground: (5, 6, 7, 8),
border: Some((9, 9, 9, 9)),
}
);
}
fn ink_rows(ctx: &RenderContext, width: u32) -> u32 {
ctx.pixels()
.chunks(width as usize * 4)
.filter(|row| row.chunks(4).any(|px| px[0] > 0x80))
.count() as u32
}
#[test]
fn icon_size_sizes_a_builtin_icon_slot() {
let mut ctx = RenderContext::new(200, 48);
let style = WidgetStyle {
icon_size: Some(30),
padding: 0,
..WidgetStyle::default()
};
let icon = ResolvedIcon::Builtin(BuiltinIcon::Power);
assert_eq!(measure_icon_content(&mut ctx, &style, &icon, "{icon}"), 30);
let default = WidgetStyle {
padding: 0,
..WidgetStyle::default()
};
assert_eq!(
measure_icon_content(&mut ctx, &default, &icon, "{icon}"),
ctx.icon_edge()
);
}
#[test]
fn icon_size_enlarges_a_text_glyph_but_not_its_label() {
let draw = |icon_size: Option<u32>, template: &str| {
let mut ctx = RenderContext::new(200, 48);
let style = WidgetStyle {
icon_size,
..WidgetStyle::default()
};
let icon = ResolvedIcon::Text("â–ˆ".into());
let width = measure_icon_content(&mut ctx, &style, &icon, template);
ctx.fill_background();
draw_icon_content(
&mut ctx,
&style,
Bounds::new(0, 0, 200, 48),
&icon,
template,
style.base_colors(),
);
(width, ink_rows(&ctx, 200))
};
let (small_w, small_h) = draw(None, "{icon}");
let (big_w, big_h) = draw(Some(32), "{icon}");
assert!(big_w > small_w, "{big_w} > {small_w}");
assert!(big_h > small_h, "{big_h} > {small_h}");
let (label_small, _) = draw(None, "{icon}50%");
let (label_big, _) = draw(Some(32), "{icon}50%");
assert!(label_big > label_small);
assert!(label_big - label_small < big_w);
}
#[test]
fn on_click_on_empty_dashboard_is_none() {
let dash = Dashboard::new(vec![]);
assert_eq!(dash.on_click(0, 0, ClickButton::Left), None);
}
#[test]
fn draw_clears_background_each_frame() {
let mut dash = Dashboard::new(vec![Box::new(ClockWidget::new(Bounds::new(0, 0, 320, 32)))]);
dash.update(&at(12, 0, 0));
let mut ctx = RenderContext::new(320, 32);
dash.draw(&mut ctx);
let px = ctx.pixels();
let last = &px[px.len() - 4..];
assert!(last[0] < 0x30 && last[1] < 0x30 && last[2] < 0x30);
assert_eq!(last[3], 0xFF);
}
fn workspaces_and_clock() -> (Dashboard, RenderContext) {
let mut workspaces = WorkspaceWidget::new(Bounds::new(0, 0, 1, 1));
workspaces.update(&Msg::Workspaces(Workspaces::new([1, 2], 1)));
let mut dash = Dashboard::with_zones(
vec![Box::new(workspaces)],
vec![],
vec![Box::new(ClockWidget::new(Bounds::new(0, 0, 1, 1)))],
);
dash.update(&at(12, 0, 0));
(dash, RenderContext::new(400, 32))
}
fn frame(dash: &mut Dashboard, ctx: &mut RenderContext) -> (Damage, Vec<u8>) {
dash.layout(ctx, 400, 32);
let damage = dash.take_damage();
dash.draw(ctx);
(damage, ctx.pixels().to_vec())
}
#[test]
fn the_first_frame_damages_the_whole_bar() {
let (mut dash, mut ctx) = workspaces_and_clock();
assert_eq!(frame(&mut dash, &mut ctx).0, Damage::Full);
}
#[test]
fn a_change_inside_fixed_slots_damages_only_the_changed_widgets() {
let (mut dash, mut ctx) = workspaces_and_clock();
frame(&mut dash, &mut ctx);
assert!(dash.update(&Msg::Workspaces(Workspaces::new([1, 2], 2))));
let workspaces = dash.left[0].bounds();
assert_eq!(frame(&mut dash, &mut ctx).0, Damage::Region(workspaces));
}
#[test]
fn widgets_changed_by_coalesced_messages_damage_the_union_of_their_slots() {
let strip = |active| {
let mut widget = WorkspaceWidget::new(Bounds::new(0, 0, 1, 1));
widget.update(&Msg::Workspaces(Workspaces::new([1, 2], active)));
widget
};
let mut dash =
Dashboard::with_zones(vec![Box::new(strip(1))], vec![], vec![Box::new(strip(1))]);
let mut ctx = RenderContext::new(400, 32);
frame(&mut dash, &mut ctx);
assert!(dash.update(&Msg::Workspaces(Workspaces::new([1, 2], 2))));
let (damage, _) = frame(&mut dash, &mut ctx);
let (left, right) = (dash.left[0].bounds(), dash.right[0].bounds());
assert_eq!(damage, Damage::Region(left.union(&right)));
}
#[test]
fn every_pixel_that_differs_between_frames_lies_inside_the_damage() {
let (mut dash, mut ctx) = workspaces_and_clock();
let (_, before) = frame(&mut dash, &mut ctx);
dash.update(&Msg::Workspaces(Workspaces::new([1, 2], 2)));
let (damage, after) = frame(&mut dash, &mut ctx);
let Damage::Region(region) = damage else {
panic!("fixed slots should give partial damage, got {damage:?}");
};
let differing: Vec<(u32, u32)> = before
.as_chunks::<4>()
.0
.iter()
.zip(after.as_chunks::<4>().0)
.enumerate()
.filter(|(_, (before, after))| before != after)
.map(|(i, _)| (i as u32 % 400, i as u32 / 400))
.collect();
assert!(!differing.is_empty(), "the active workspace moved");
assert!(
differing.iter().all(|&(x, y)| region.contains(x, y)),
"pixels outside {region:?} changed"
);
}
#[test]
fn a_slot_that_moves_or_resizes_damages_the_whole_bar() {
let (mut dash, mut ctx) = workspaces_and_clock();
frame(&mut dash, &mut ctx);
dash.update(&Msg::Workspaces(Workspaces::new([1, 2, 3], 1)));
assert_eq!(frame(&mut dash, &mut ctx).0, Damage::Full);
}
#[test]
fn a_repaint_nothing_asked_for_and_an_invalidated_frame_damage_the_whole_bar() {
let (mut dash, mut ctx) = workspaces_and_clock();
frame(&mut dash, &mut ctx);
assert_eq!(frame(&mut dash, &mut ctx).0, Damage::Full);
dash.update(&Msg::Workspaces(Workspaces::new([1, 2], 2)));
dash.invalidate();
assert_eq!(frame(&mut dash, &mut ctx).0, Damage::Full);
}
#[test]
fn a_damage_limited_repaint_matches_a_full_repaint() {
for background in [(0x18, 0x18, 0x18, 0xFF), (0x10, 0x20, 0x30, 0x80)] {
let settings = crate::render::RenderSettings {
background,
..crate::render::RenderSettings::default()
};
let (mut dash, _) = workspaces_and_clock();
let mut ctx = RenderContext::with_settings(400, 32, settings.clone());
frame(&mut dash, &mut ctx);
assert!(dash.update(&Msg::Workspaces(Workspaces::new([1, 2], 2))));
dash.layout(&mut ctx, 400, 32);
let damage = dash.take_damage();
let repainted = dash.draw_damage(&mut ctx, damage);
assert!(
matches!(repainted, Damage::Region(region) if region.width < 400),
"only part of the bar is repainted: {repainted:?}"
);
let mut full = RenderContext::with_settings(400, 32, settings);
dash.layout(&mut full, 400, 32);
dash.draw(&mut full);
assert!(ctx.pixels() == full.pixels(), "partial repaint differs");
}
}
#[test]
fn damage_union_widens_regions_and_full_absorbs() {
let a = Damage::Region(Bounds::new(0, 0, 10, 10));
let b = Damage::Region(Bounds::new(20, 0, 10, 10));
assert_eq!(a.union(b), Damage::Region(Bounds::new(0, 0, 30, 10)));
assert_eq!(a.union(Damage::Full), Damage::Full);
assert_eq!(Damage::Full.union(b), Damage::Full);
}
#[test]
fn bounds_union_covers_both_rectangles() {
let a = Bounds::new(10, 4, 20, 8);
let b = Bounds::new(50, 0, 5, 30);
assert_eq!(a.union(&b), Bounds::new(10, 0, 45, 30));
assert_eq!(a.union(&a), a);
}
#[test]
fn styled_workspace_pixels_at_scale_two_lie_inside_the_damage() {
let style = WidgetStyle {
background: Some((0x1c, 0x0e, 0x1e, 0xe8)),
foreground: (0x9a, 0x8a, 0x98, 0xff),
accent: (0x7d, 0xec, 0xff, 0xff),
border: Some((0x3d, 0x1f, 0x3a, 0xff)),
border_width: 1,
radius: 2,
..WidgetStyle::default()
};
let mut workspaces = WorkspaceWidget::new(Bounds::new(0, 0, 1, 1)).with_style(style);
workspaces.update(&Msg::Workspaces(Workspaces::new([1, 2], 1)));
let mut dash = Dashboard::with_zones(
vec![Box::new(workspaces)],
vec![],
vec![Box::new(ClockWidget::new(Bounds::new(0, 0, 1, 1)))],
)
.with_spacing(4, 4);
dash.update(&at(12, 0, 0));
const WIDTH: u32 = 400;
const HEIGHT: u32 = 76;
let mut ctx = RenderContext::with_settings(
WIDTH,
HEIGHT,
crate::render::RenderSettings {
background: (0, 0, 0, 0),
scale: 2,
font_size: 30.0,
..crate::render::RenderSettings::default()
},
);
let paint = |dash: &mut Dashboard, ctx: &mut RenderContext| {
dash.layout(ctx, WIDTH, HEIGHT);
let damage = dash.take_damage();
dash.draw_damage(ctx, damage);
(damage, ctx.pixels().to_vec())
};
let (_, before) = paint(&mut dash, &mut ctx);
assert!(dash.update(&Msg::Workspaces(Workspaces::new([1, 2], 2))));
let (damage, after) = paint(&mut dash, &mut ctx);
let Damage::Region(region) = damage else {
panic!("fixed slots should give partial damage, got {damage:?}");
};
let outside: Vec<(u32, u32)> = before
.as_chunks::<4>()
.0
.iter()
.zip(after.as_chunks::<4>().0)
.enumerate()
.filter(|(_, (before, after))| before != after)
.map(|(i, _)| (i as u32 % WIDTH, i as u32 / WIDTH))
.filter(|&(x, y)| !region.contains(x, y))
.collect();
assert!(
outside.is_empty(),
"styled workspace pixels outside {region:?}: {outside:?}"
);
let mut full = RenderContext::with_settings(WIDTH, HEIGHT, ctx.settings().clone());
dash.layout(&mut full, WIDTH, HEIGHT);
dash.draw(&mut full);
assert!(after == full.pixels(), "partial repaint differs");
}
}