use crate::render::{Bounds, RenderContext};
use super::{Msg, Widget, WidgetStyle, draw_text_pill, glyph_label, measure_text_pill};
const DEFAULT_MAX_LENGTH: u32 = 100;
const TRUNCATE_SUFFIX: char = '\u{2026}';
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveWindow {
class: String,
title: String,
}
impl ActiveWindow {
pub fn new(class: impl Into<String>, title: impl Into<String>) -> Self {
let class = class.into();
let title = title.into();
Self {
class: class.trim().to_string(),
title: title.trim().to_string(),
}
}
pub fn class(&self) -> &str {
&self.class
}
pub fn title(&self) -> &str {
&self.title
}
pub fn is_empty(&self) -> bool {
self.class.is_empty() && self.title.is_empty()
}
}
pub struct TitleWidget {
bounds: Bounds,
monitor: String,
state: Option<ActiveWindow>,
text: String,
style: WidgetStyle,
max_length: u32,
}
impl TitleWidget {
pub fn new(bounds: Bounds) -> Self {
Self {
bounds,
monitor: String::new(),
state: None,
text: String::new(),
style: WidgetStyle::default(),
max_length: DEFAULT_MAX_LENGTH,
}
}
pub fn with_style(mut self, style: WidgetStyle) -> Self {
self.style = style;
self
}
pub fn with_max_length(mut self, max_length: u32) -> Self {
self.max_length = max_length;
self
}
pub fn with_monitor(mut self, monitor: impl Into<String>) -> Self {
self.monitor = monitor.into();
self
}
pub fn title(&self) -> &str {
&self.text
}
fn display_text(&self) -> String {
if self.text.is_empty() {
return String::new();
}
glyph_label(self.style.glyph(""), &self.text)
}
}
impl Widget for TitleWidget {
fn update(&mut self, msg: &Msg) -> bool {
match msg {
Msg::ActiveWindow { monitor, window } => {
if !self.monitor.is_empty() && self.monitor != monitor.as_str() {
return false;
}
let normalized = window.clone().filter(|w| !w.is_empty());
let next_text = match normalized.as_ref() {
Some(w) => truncate_chars(w.title(), self.max_length),
None => String::new(),
};
if self.text == next_text {
return false;
}
self.state = normalized;
self.text = next_text;
true
}
_ => false,
}
}
fn draw(&self, ctx: &mut RenderContext) {
let text = self.display_text();
if text.is_empty() {
return;
}
draw_text_pill(
ctx,
&self.style,
self.bounds,
&text,
self.style.base_colors(),
);
}
fn measure(&self, ctx: &mut RenderContext, _height: u32) -> u32 {
measure_text_pill(ctx, &self.style, &self.display_text())
}
fn bounds(&self) -> Bounds {
self.bounds
}
fn set_bounds(&mut self, bounds: Bounds) {
self.bounds = bounds;
}
}
fn truncate_chars(s: &str, max_length: u32) -> String {
if max_length == 0 {
return s.to_string();
}
let total = s.chars().count();
let cap = max_length as usize;
if total <= cap {
return s.to_string();
}
let keep = cap.saturating_sub(1);
let mut out: String = s.chars().take(keep).collect();
out.push(TRUNCATE_SUFFIX);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::ClickButton;
fn widget() -> TitleWidget {
TitleWidget::new(Bounds::new(0, 0, 320, 32)).with_monitor("DP-1")
}
fn focus(monitor: &str, class: &str, title: &str) -> Msg {
Msg::ActiveWindow {
monitor: monitor.to_string(),
window: Some(ActiveWindow::new(class, title)),
}
}
fn absent(monitor: &str) -> Msg {
Msg::ActiveWindow {
monitor: monitor.to_string(),
window: None,
}
}
#[test]
fn active_window_trims_class_and_title() {
let window = ActiveWindow::new(" firefox ", " GitHub ");
assert_eq!(window.class(), "firefox");
assert_eq!(window.title(), "GitHub");
assert!(!window.is_empty());
}
#[test]
fn active_window_normalizes_whitespace_only_to_empty() {
let window = ActiveWindow::new(" ", "\t");
assert_eq!(window.class(), "");
assert_eq!(window.title(), "");
assert!(window.is_empty());
}
#[test]
fn first_reading_changes_state_and_sets_title() {
let mut w = widget();
assert_eq!(w.title(), "");
assert!(w.update(&focus("DP-1", "firefox", "GitHub")));
assert_eq!(w.title(), "GitHub");
}
#[test]
fn identical_reading_is_not_a_visible_change() {
let mut w = widget();
assert!(w.update(&focus("DP-1", "firefox", "GitHub")));
assert!(!w.update(&focus("DP-1", "firefox", "GitHub")));
assert_eq!(w.title(), "GitHub");
}
#[test]
fn trimmed_equivalent_inputs_are_not_a_visible_change() {
let mut w = widget();
assert!(w.update(&focus("DP-1", "firefox", "GitHub")));
assert!(!w.update(&focus("DP-1", " firefox ", " GitHub ")));
assert_eq!(w.title(), "GitHub");
}
#[test]
fn title_change_is_a_visible_change() {
let mut w = widget();
assert!(w.update(&focus("DP-1", "firefox", "GitHub")));
assert!(w.update(&focus("DP-1", "firefox", "GitLab")));
assert_eq!(w.title(), "GitLab");
}
#[test]
fn window_going_absent_is_a_visible_change_then_blank() {
let mut w = widget();
assert!(w.update(&focus("DP-1", "firefox", "GitHub")));
assert!(w.update(&absent("DP-1")));
assert_eq!(w.title(), "");
}
#[test]
fn absent_reading_before_any_window_is_not_a_change() {
let mut w = widget();
assert!(!w.update(&absent("DP-1")));
assert_eq!(w.title(), "");
}
#[test]
fn whitespace_only_snapshot_normalizes_to_absent() {
let mut w = widget();
assert!(w.update(&focus("DP-1", "firefox", "GitHub")));
assert!(w.update(&focus("DP-1", " ", " ")));
assert_eq!(w.title(), "");
}
#[test]
fn messages_addressed_to_other_monitors_are_dropped() {
let mut dp = widget();
let mut hdmi = TitleWidget::new(Bounds::new(0, 0, 320, 32)).with_monitor("HDMI-A-1");
assert!(dp.update(&focus("DP-1", "firefox", "GitHub")));
assert!(hdmi.update(&focus("HDMI-A-1", "kitty", "vim")));
assert!(!dp.update(&focus("HDMI-A-1", "kitty", "vim")));
assert_eq!(dp.title(), "GitHub");
assert!(dp.update(&focus("DP-1", "firefox", "GitLab")));
assert_eq!(dp.title(), "GitLab");
assert_eq!(hdmi.title(), "vim");
}
#[test]
fn empty_monitor_widget_is_a_wildcard_accepting_every_message() {
let mut w = TitleWidget::new(Bounds::new(0, 0, 320, 32));
assert!(w.update(&focus("DP-1", "firefox", "GitHub")));
assert!(w.update(&focus("HDMI-A-1", "kitty", "vim")));
assert_eq!(w.title(), "vim");
}
#[test]
fn max_length_zero_disables_truncation_even_on_a_long_title() {
let mut w = TitleWidget::new(Bounds::new(0, 0, 320, 32))
.with_monitor("DP-1")
.with_max_length(0);
let long = "x".repeat(5000);
assert!(w.update(&focus("DP-1", "kitty", &long)));
assert_eq!(w.title().chars().count(), 5000);
assert!(!w.update(&focus("DP-1", "kitty", &long)));
}
#[test]
fn max_length_caps_long_title_with_ellipsis() {
let mut w = TitleWidget::new(Bounds::new(0, 0, 320, 32))
.with_monitor("DP-1")
.with_max_length(10);
let long = "abcdefghijklmno"; assert!(w.update(&focus("DP-1", "kitty", long)));
let displayed = w.title();
assert_eq!(displayed.chars().count(), 10);
assert!(displayed.ends_with('\u{2026}'));
assert_eq!(
&displayed[..displayed.len() - '\u{2026}'.len_utf8()],
"abcdefghi"
);
}
#[test]
fn max_length_at_exact_boundary_does_not_truncate() {
let mut w = TitleWidget::new(Bounds::new(0, 0, 320, 32))
.with_monitor("DP-1")
.with_max_length(5);
assert!(w.update(&focus("DP-1", "kitty", "abcde")));
assert_eq!(w.title(), "abcde");
}
#[test]
fn max_length_truncation_reports_visible_change_only_when_output_differs() {
let mut w = TitleWidget::new(Bounds::new(0, 0, 320, 32))
.with_monitor("DP-1")
.with_max_length(5);
assert!(w.update(&focus("DP-1", "kitty", "abcdefgh")));
assert!(!w.update(&focus("DP-1", "kitty", "abcdefxyz")));
assert_eq!(w.title(), "abcd…");
}
#[test]
fn max_length_counts_chars_not_bytes_for_multibyte_titles() {
let mut w = TitleWidget::new(Bounds::new(0, 0, 320, 32))
.with_monitor("DP-1")
.with_max_length(5);
let title = "日本語のとても長いタイトル";
assert!(w.update(&focus("DP-1", "kitty", title)));
assert_eq!(w.title().chars().count(), 5);
assert_eq!(w.title(), "日本語の…");
}
#[test]
fn set_bounds_repositions_the_widget() {
let mut widget = TitleWidget::new(Bounds::new(0, 0, 1, 1));
widget.set_bounds(Bounds::new(0, 0, 1920, 32));
assert_eq!(widget.bounds(), Bounds::new(0, 0, 1920, 32));
}
#[test]
fn click_is_a_no_op() {
let mut widget = TitleWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&focus("DP-1", "firefox", "GitHub")));
assert_eq!(widget.on_click(16, 16, ClickButton::Left), None);
}
}