use super::render_util::{
apply_rounded_shape, blit_glyph_at, blit_glyphs, lerp_color, load_system_font,
rasterise_glyphs, PixelBuffer,
};
use crate::error::TalkError;
use x11rb::connection::Connection;
use x11rb::protocol::xproto::*;
use x11rb::COPY_DEPTH_FROM_PARENT;
const TOP_OFFSET: i16 = 4;
const FPS: u32 = 60;
const BADGE_H: u16 = 52;
const TEXT_W: u16 = 1200;
const TEXT_LINE_H: u16 = 44;
const MAX_TEXT_LINES: u16 = 5;
const TEXT_GAP: i16 = 4;
const TEXT_FONT_SIZE: f32 = 30.0;
const TEXT_CORNER_RADIUS: usize = 10;
const BG: [u8; 4] = [0x00, 0x00, 0x00, 0xFF]; const TEXT_COLOR: [u8; 4] = [0xFF, 0xFF, 0xFF, 0xFF]; const ERROR_COLOR: [u8; 4] = [0x55, 0x55, 0xFF, 0xFF]; const DOT_HI: [u8; 4] = [0xCC, 0xCC, 0xCC, 0xFF]; const DOT_LO: [u8; 4] = [0x33, 0x33, 0x33, 0xFF]; const DOT_CYCLE_FRAMES: f32 = 90.0;
fn load_viz_font() -> Option<fontdue::Font> {
load_system_font(TEXT_FONT_SIZE)
}
fn viz_rasterise_glyphs(
text: &str,
font: &fontdue::Font,
) -> (Vec<(fontdue::Metrics, Vec<u8>)>, usize) {
rasterise_glyphs(text, font, TEXT_FONT_SIZE)
}
fn dot_wave(frame: u64) -> [f32; 3] {
let phase = frame as f32 * 2.0 * std::f32::consts::PI / DOT_CYCLE_FRAMES;
let mut out = [0.0f32; 3];
for (i, val) in out.iter_mut().enumerate() {
let offset = i as f32 * 2.0 * std::f32::consts::PI / 3.0;
*val = ((phase - offset).sin() + 1.0) / 2.0;
}
out
}
fn render_text(pb: &mut PixelBuffer, text: &str, font: &fontdue::Font, dot_brightnesses: [f32; 3]) {
let w = pb.width;
let h = pb.height;
let baseline = (h as i32 * 3) / 4;
let (text_glyphs, text_w) = if text.is_empty() {
(Vec::new(), 0)
} else {
viz_rasterise_glyphs(text, font)
};
let (dot_metrics, dot_bitmap) = font.rasterize('●', TEXT_FONT_SIZE);
let space_metrics = font.metrics(' ', TEXT_FONT_SIZE);
let space_adv = space_metrics.advance_width as usize;
let dot_adv = dot_metrics.advance_width as usize;
let dot_area_w = space_adv + dot_adv + space_adv + dot_adv + space_adv + dot_adv;
let total_w = text_w + dot_area_w;
let (text_start_x, first_visible_glyph, partial_clip) = if total_w <= w {
let x0 = (w - total_w) as i32 / 2;
(x0, 0usize, 0i32)
} else {
let skip_px = total_w - w;
let mut skipped = 0usize;
let mut first = 0usize;
for (i, (m, _)) in text_glyphs.iter().enumerate() {
let adv = m.advance_width as usize;
if skipped + adv > skip_px {
first = i;
break;
}
skipped += adv;
first = i + 1;
}
let partial = skip_px.saturating_sub(skipped) as i32;
(-(partial), first, partial)
};
let _ = partial_clip;
let visible_glyphs = &text_glyphs[first_visible_glyph..];
blit_glyphs(pb, visible_glyphs, text_start_x, TEXT_COLOR, 1.0);
let visible_text_w: usize = visible_glyphs
.iter()
.map(|(m, _)| m.advance_width as usize)
.sum();
let mut cursor_x = text_start_x + visible_text_w as i32;
cursor_x += space_adv as i32; for (i, &brightness) in dot_brightnesses.iter().enumerate() {
let color = lerp_color(DOT_LO, DOT_HI, brightness);
blit_glyph_at(
pb,
&dot_metrics,
&dot_bitmap,
cursor_x,
baseline,
w,
h,
color,
1.0,
);
cursor_x += dot_adv as i32;
if i < 2 {
cursor_x += space_adv as i32;
}
}
}
fn render_text_status(
pb: &mut PixelBuffer,
text: &str,
font: &fontdue::Font,
color: [u8; 4],
opacity: f32,
) {
let w = pb.width;
let (glyphs, text_w) = if text.is_empty() {
return;
} else {
viz_rasterise_glyphs(text, font)
};
let (start_x, first_visible) = if text_w <= w {
((w - text_w) as i32 / 2, 0usize)
} else {
let skip_px = text_w - w;
let mut skipped = 0usize;
let mut first = 0usize;
for (i, (m, _)) in glyphs.iter().enumerate() {
let adv = m.advance_width as usize;
if skipped + adv > skip_px {
first = i;
break;
}
skipped += adv;
first = i + 1;
}
let partial = skip_px.saturating_sub(skipped) as i32;
(-(partial), first)
};
blit_glyphs(pb, &glyphs[first_visible..], start_x, color, opacity);
}
const MESSAGE_TTL_SECS: f32 = 3.0;
const MESSAGE_FADE_DELAY_SECS: f32 = 1.0;
enum VizCommand {
Show,
Hide,
Text { text: String },
PushMessage { text: String },
Quit,
}
pub struct VisualizerHandle {
tx: std::sync::mpsc::Sender<VizCommand>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl VisualizerHandle {
pub fn new(_text: bool) -> Result<Self, TalkError> {
let geom = super::monitor::primary_monitor_geometry()?;
let (tx, rx) = std::sync::mpsc::channel();
let thread = std::thread::Builder::new()
.name("visualizer".into())
.spawn(move || {
if let Err(e) = visualizer_thread(rx, geom) {
log::error!("visualizer thread error: {}", e);
}
})
.map_err(|e| TalkError::Audio(format!("failed to spawn visualizer thread: {}", e)))?;
Ok(Self {
tx,
thread: Some(thread),
})
}
pub fn show(&self, _badge_width: u16) {
let _ = self.tx.send(VizCommand::Show);
}
pub fn hide(&self) {
let _ = self.tx.send(VizCommand::Hide);
}
pub fn set_text(&self, text: &str) {
let _ = self.tx.send(VizCommand::Text {
text: text.to_string(),
});
}
pub fn push_message(&self, text: &str) {
let _ = self.tx.send(VizCommand::PushMessage {
text: text.to_string(),
});
}
pub fn message_pusher(&self) -> impl Fn(String) + Send + 'static {
let tx = self.tx.clone();
move |text: String| {
let _ = tx.send(VizCommand::PushMessage { text });
}
}
}
impl Drop for VisualizerHandle {
fn drop(&mut self) {
let _ = self.tx.send(VizCommand::Quit);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
fn visualizer_thread(
rx: std::sync::mpsc::Receiver<VizCommand>,
geom: super::monitor::MonitorGeometry,
) -> Result<(), TalkError> {
let (conn, screen_num) = x11rb::connect(None)
.map_err(|e| TalkError::Config(format!("visualizer X11 connect: {}", e)))?;
let screen = &conn.setup().roots[screen_num];
let root = screen.root;
let depth = screen.root_depth;
let (mon_x, mon_y, mon_w, _mon_h) = geom;
let text_x = mon_x + (mon_w as i16 / 2) - (TEXT_W as i16 / 2);
let text_y = mon_y + TOP_OFFSET + BADGE_H as i16 + TEXT_GAP;
let text_win = {
let values = CreateWindowAux::new()
.background_pixel(screen.black_pixel)
.border_pixel(0)
.override_redirect(1u32)
.event_mask(EventMask::EXPOSURE);
let win = conn
.generate_id()
.map_err(|e| TalkError::Config(format!("X11 id: {}", e)))?;
conn.create_window(
COPY_DEPTH_FROM_PARENT,
win,
root,
text_x,
text_y,
TEXT_W,
TEXT_LINE_H * MAX_TEXT_LINES,
0,
WindowClass::INPUT_OUTPUT,
0,
&values,
)
.map_err(|e| TalkError::Config(format!("X11 create text win: {}", e)))?;
apply_rounded_shape(&conn, win, TEXT_W, TEXT_LINE_H, TEXT_CORNER_RADIUS)?;
win
};
let text_gc = {
let gc = conn
.generate_id()
.map_err(|e| TalkError::Config(format!("X11 id: {}", e)))?;
conn.create_gc(gc, text_win, &CreateGCAux::new())
.map_err(|e| TalkError::Config(format!("X11 gc: {}", e)))?;
gc
};
let _ = conn.flush();
let mut text_pb = PixelBuffer::new(TEXT_W as usize, TEXT_LINE_H as usize);
let font_rx = {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("viz-font-loader".into())
.spawn(move || {
let _ = tx.send(load_viz_font());
})
.map_err(|e| TalkError::Config(format!("font loader thread: {}", e)))?;
rx
};
let mut font: Option<fontdue::Font> = None;
let frame_dur = std::time::Duration::from_micros(1_000_000 / FPS as u64);
let mut is_showing = false;
let mut current_text = String::new();
let mut text_is_mapped = false;
let mut ttl_messages: Vec<(String, std::time::Instant)> = Vec::new();
let mut prev_text_lines: u16 = 0;
let mut frame_counter: u64 = 0;
let mut should_quit = false;
loop {
if !is_showing && ttl_messages.is_empty() {
if should_quit {
break;
}
match rx.recv() {
Ok(VizCommand::Show) => {
is_showing = true;
}
Ok(VizCommand::Hide) => {}
Ok(VizCommand::Text { text }) => {
current_text = text;
}
Ok(VizCommand::PushMessage { text }) => {
let expires = std::time::Instant::now()
+ std::time::Duration::from_secs_f32(MESSAGE_TTL_SECS);
ttl_messages.push((text, expires));
}
Ok(VizCommand::Quit) | Err(_) => {
should_quit = true;
}
}
continue;
}
if !should_quit {
loop {
match rx.try_recv() {
Ok(VizCommand::Hide) => {
is_showing = false;
}
Ok(VizCommand::Text { text }) => {
current_text = text;
}
Ok(VizCommand::PushMessage { text }) => {
let expires = std::time::Instant::now()
+ std::time::Duration::from_secs_f32(MESSAGE_TTL_SECS);
ttl_messages.push((text, expires));
}
Ok(VizCommand::Show) => {
is_showing = true;
}
Ok(VizCommand::Quit) | Err(std::sync::mpsc::TryRecvError::Disconnected) => {
is_showing = false;
should_quit = true;
break;
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
}
}
}
if should_quit && ttl_messages.is_empty() {
break;
}
if !is_showing && ttl_messages.is_empty() {
continue;
}
if font.is_none() {
if let Ok(loaded) = font_rx.try_recv() {
font = loaded;
}
}
let frame_start = std::time::Instant::now();
let now_ttl = std::time::Instant::now();
ttl_messages.retain(|(_, expires)| *expires > now_ttl);
let fade_duration = MESSAGE_TTL_SECS - MESSAGE_FADE_DELAY_SECS;
let mut text_lines: Vec<(&str, f32)> = ttl_messages
.iter()
.rev()
.map(|(t, expires)| {
let remaining = expires.duration_since(now_ttl).as_secs_f32();
let elapsed = MESSAGE_TTL_SECS - remaining;
let alpha = if elapsed < MESSAGE_FADE_DELAY_SECS {
1.0
} else {
((MESSAGE_TTL_SECS - elapsed) / fade_duration).clamp(0.0, 1.0)
};
(t.as_str(), alpha)
})
.collect();
let mut n_ttl = text_lines.len();
if !current_text.is_empty() {
text_lines.push((¤t_text, 1.0));
}
if text_lines.len() > MAX_TEXT_LINES as usize {
let drop = text_lines.len() - MAX_TEXT_LINES as usize;
text_lines = text_lines.split_off(drop);
n_ttl = n_ttl.saturating_sub(drop);
}
let n_lines = text_lines.len() as u16;
{
let want_visible = n_lines > 0;
if want_visible && n_lines != prev_text_lines {
let new_h = n_lines.max(1) * TEXT_LINE_H;
let _ = conn.configure_window(
text_win,
&x11rb::protocol::xproto::ConfigureWindowAux::new().height(u32::from(new_h)),
);
let _ = apply_rounded_shape(&conn, text_win, TEXT_W, new_h, TEXT_CORNER_RADIUS);
prev_text_lines = n_lines;
}
if want_visible && !text_is_mapped {
let _ = conn.map_window(text_win);
text_is_mapped = true;
} else if !want_visible && text_is_mapped {
let _ = conn.unmap_window(text_win);
text_is_mapped = false;
prev_text_lines = 0;
}
}
if let Some(ref f) = font {
let dot_br = dot_wave(frame_counter);
for (i, (line, alpha)) in text_lines.iter().enumerate() {
text_pb.clear_rounded(BG, TEXT_CORNER_RADIUS);
if i < n_ttl {
render_text_status(&mut text_pb, line, f, ERROR_COLOR, *alpha);
} else {
render_text(&mut text_pb, line, f, dot_br);
}
let _ = conn.put_image(
ImageFormat::Z_PIXMAP,
text_win,
text_gc,
TEXT_W,
TEXT_LINE_H,
0,
i as i16 * TEXT_LINE_H as i16,
0,
depth,
&text_pb.data,
);
}
}
frame_counter += 1;
let _ = conn.flush();
let elapsed = frame_start.elapsed();
if elapsed < frame_dur {
std::thread::sleep(frame_dur - elapsed);
}
}
let _ = conn.free_gc(text_gc);
let _ = conn.destroy_window(text_win);
let _ = conn.flush();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_text_no_panic_with_font() {
if let Some(font) = load_viz_font() {
let mut pb = PixelBuffer::new(600, 36);
pb.clear(BG);
render_text(&mut pb, "Hello world", &font, [0.5, 0.5, 0.5]);
let non_bg = pb.data.chunks_exact(4).filter(|p| *p != BG).count();
assert!(non_bg > 0, "text should produce non-background pixels");
}
}
#[test]
fn render_text_dots_always_present() {
if let Some(font) = load_viz_font() {
let mut pb = PixelBuffer::new(600, 36);
pb.clear(BG);
render_text(&mut pb, "", &font, [1.0, 1.0, 1.0]);
let non_bg = pb.data.chunks_exact(4).filter(|p| *p != BG).count();
assert!(non_bg > 0, "dots alone should produce pixels");
}
}
#[test]
fn render_text_dots_dim_still_visible() {
if let Some(font) = load_viz_font() {
let mut pb = PixelBuffer::new(600, 36);
pb.clear(BG);
render_text(&mut pb, "", &font, [0.0, 0.0, 0.0]);
let non_bg = pb.data.chunks_exact(4).filter(|p| *p != BG).count();
assert!(non_bg > 0, "dim dots should still differ from BG");
}
}
#[test]
fn dot_wave_returns_valid_range() {
for frame in 0..200 {
let vals = dot_wave(frame);
for v in &vals {
assert!(
(0.0..=1.0).contains(v),
"dot_wave({}) produced {} which is out of [0,1]",
frame,
v
);
}
}
}
#[test]
fn render_text_overflow_clips_left() {
if let Some(font) = load_viz_font() {
let mut pb = PixelBuffer::new(80, 36);
pb.clear(BG);
render_text(
&mut pb,
"This is a very long sentence that will not fit",
&font,
[0.8, 0.5, 0.2],
);
}
}
}