Files
lightbar/src/render.rs
2026-09-29 13:01:10 +02:00

1202 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::{BTreeMap, VecDeque};
use cosmic_text::{Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, SwashCache};
use crate::{
config::{Config, ModuleConfig, Rgba, Style, Theme},
model::{IconPixmap, ModuleSnapshot, ModuleStore, PopupContent, PopupModel},
};
#[derive(Debug, Copy, Clone, Default, PartialEq)]
pub struct Rect {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
impl Rect {
pub fn contains(self, x: f64, y: f64) -> bool {
x >= self.x && y >= self.y && x < self.x + self.width && y < self.y + self.height
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct HitBox {
pub rect: Rect,
pub module: String,
pub segment: Option<String>,
}
#[derive(Debug, Clone)]
struct RenderSegment {
module: String,
segment: Option<String>,
text: String,
icon: Option<IconPixmap>,
style: Style,
rect: Rect,
}
#[derive(Debug, Clone, Default)]
pub struct RenderResult {
pub hit_boxes: Vec<HitBox>,
}
#[derive(Debug)]
pub struct Renderer {
font_system: FontSystem,
swash_cache: SwashCache,
text_width_cache: VecDeque<(TextWidthKey, f64)>,
segment_cache: VecDeque<(SegmentCacheKey, Vec<u8>)>,
}
#[derive(Debug, Clone, Eq, PartialEq)]
struct TextWidthKey {
text: String,
logical_height_bits: u64,
scale: u32,
font_family: String,
font_size_bits: u32,
}
#[derive(Debug, Clone, Eq, PartialEq)]
struct SegmentCacheKey {
width: u32,
height: u32,
scale: u32,
text: String,
icon: Option<IconPixmap>,
style: Style,
font_family: String,
font_size_bits: u32,
}
impl Default for Renderer {
fn default() -> Self {
Self {
font_system: FontSystem::new(),
swash_cache: SwashCache::new(),
text_width_cache: VecDeque::new(),
segment_cache: VecDeque::new(),
}
}
}
impl Renderer {
#[allow(clippy::too_many_arguments)]
pub fn render_bar(
&mut self,
canvas: &mut [u8],
physical_width: u32,
physical_height: u32,
scale: u32,
output: &str,
config: &Config,
theme: &Theme,
modules: &ModuleStore,
hovered: Option<(&str, Option<&str>)>,
) -> RenderResult {
fill(
canvas,
physical_width,
physical_height,
theme.bar.background,
);
let logical_width = f64::from(physical_width) / f64::from(scale);
let logical_height = f64::from(physical_height) / f64::from(scale);
let groups = [
&config.layout.left,
&config.layout.center,
&config.layout.right,
];
let mut measured = Vec::with_capacity(3);
for group in groups {
measured.push(self.measure_group(
group,
output,
logical_height,
scale,
config,
theme,
modules,
hovered,
));
}
let left_width = group_width(&measured[0]);
let center_width = group_width(&measured[1]);
let right_width = group_width(&measured[2]);
position_group(&mut measured[0], 0.0, logical_height);
let right_start = (logical_width - right_width).max(left_width);
position_group(&mut measured[2], right_start, logical_height);
let centered = ((logical_width - center_width) / 2.0)
.max(left_width)
.min((right_start - center_width).max(left_width));
position_group(&mut measured[1], centered, logical_height);
let mut result = RenderResult::default();
for segment in measured.into_iter().flatten() {
self.draw_segment(
canvas,
physical_width,
physical_height,
scale,
&segment,
theme,
);
result.hit_boxes.push(HitBox {
rect: segment.rect,
module: segment.module,
segment: segment.segment,
});
}
result
}
pub fn popup_logical_size(
model: &PopupModel,
configured_width: u32,
maximum_height: u32,
) -> (u32, u32) {
let content_height = match &model.content {
PopupContent::Rows(rows) => 42_u32.saturating_add(
u32::try_from(rows.len())
.unwrap_or(u32::MAX)
.saturating_mul(34),
),
PopupContent::Calendar(_) => 42 + 7 * 30,
PopupContent::Slider(slider) => 42_u32.saturating_add(56).saturating_add(
u32::try_from(slider.detail_rows.len())
.unwrap_or(u32::MAX)
.saturating_mul(34),
),
// Reserve the same viewport when switching between mixer and device lists.
PopupContent::Audio(_) => 420,
};
(configured_width, content_height.min(maximum_height).max(80))
}
#[allow(clippy::too_many_arguments)]
pub fn render_popup(
&mut self,
canvas: &mut [u8],
physical_width: u32,
physical_height: u32,
scale: u32,
module: &str,
model: &PopupModel,
theme: &Theme,
) -> RenderResult {
fill(
canvas,
physical_width,
physical_height,
theme.popup.background,
);
draw_border(
canvas,
physical_width,
physical_height,
(0, 0, physical_width, physical_height),
theme.popup.border,
theme.popup.border_width.saturating_mul(scale),
);
let logical_width = f64::from(physical_width) / f64::from(scale);
let logical_height = f64::from(physical_height) / f64::from(scale);
let padding = f64::from(theme.popup.padding);
let title_rect = Rect {
x: padding,
y: padding,
width: (logical_width - padding * 2.0).max(0.0),
height: 30.0,
};
self.draw_popup_text(
canvas,
physical_width,
physical_height,
scale,
module,
&model.title,
"focused",
title_rect,
theme,
);
let mut result = RenderResult::default();
let mut y = padding + 34.0;
match &model.content {
PopupContent::Audio(audio) => {
let controls = audio.controls();
let available = (logical_height - y - padding - 24.0).max(0.0);
let offset = audio.offset.min(controls.len().saturating_sub(1));
let mut used = 0.0;
let mut shown = 0;
for control in controls.iter().skip(offset) {
let height = control.height();
if used + height > available {
break;
}
let rect = Rect {
x: padding,
y: y + used,
width: (logical_width - 2.0 * padding).max(1.0),
height: height - 2.0,
};
let focused = control.id.is_some() && control.id == audio.focused;
let state = if focused || control.active {
"focused"
} else {
"normal"
};
self.draw_popup_text(
canvas,
physical_width,
physical_height,
scale,
module,
&control.label,
state,
Rect {
height: 30.0,
..rect
},
theme,
);
if let Some(value) = control.slider {
let track = Rect {
x: rect.x + 8.0,
y: rect.y + 38.0,
width: (rect.width - 16.0).max(1.0),
height: 6.0,
};
fill_rect(
canvas,
physical_width,
physical_height,
physical_rect(track, scale),
theme.popup.border,
);
fill_rect(
canvas,
physical_width,
physical_height,
physical_rect(
Rect {
width: track.width * (value / 150.0).clamp(0.0, 1.0),
..track
},
scale,
),
theme.module.foreground,
);
}
if control.id.is_some() {
result.hit_boxes.push(HitBox {
rect,
module: module.to_owned(),
segment: control.id.clone(),
});
}
used += height;
shown += 1;
}
if offset > 0 || offset + shown < controls.len() {
for (id, text, x, enabled) in [
("audio-scroll-up", "↑ Previous", padding, offset > 0),
(
"audio-scroll-down",
"↓ More",
logical_width / 2.0,
offset + shown < controls.len(),
),
] {
let rect = Rect {
x,
y: logical_height - padding - 24.0,
width: (logical_width / 2.0 - padding).max(1.0),
height: 24.0,
};
self.draw_popup_text(
canvas,
physical_width,
physical_height,
scale,
module,
text,
if enabled { "normal" } else { "disabled" },
rect,
theme,
);
if enabled {
result.hit_boxes.push(HitBox {
rect,
module: module.to_owned(),
segment: Some(id.to_owned()),
});
}
}
}
}
PopupContent::Rows(rows) => {
for row in rows {
if y + 32.0 > logical_height - padding {
break;
}
let rect = Rect {
x: padding,
y,
width: (logical_width - padding * 2.0).max(0.0),
height: 32.0,
};
let text = row.detail.as_ref().map_or_else(
|| row.label.clone(),
|detail| format!("{} {}", row.label, detail),
);
self.draw_popup_text(
canvas,
physical_width,
physical_height,
scale,
module,
&text,
if row.enabled { &row.state } else { "disabled" },
rect,
theme,
);
if row.enabled {
result.hit_boxes.push(HitBox {
rect,
module: module.to_owned(),
segment: row.id.clone(),
});
}
y += 34.0;
}
}
PopupContent::Calendar(calendar) => {
let button_width = 32.0;
for (id, text, x) in [
("previous-month", "‹", padding),
("next-month", "›", logical_width - padding - button_width),
] {
let rect = Rect {
x,
y,
width: button_width,
height: 28.0,
};
self.draw_popup_text(
canvas,
physical_width,
physical_height,
scale,
module,
text,
"normal",
rect,
theme,
);
result.hit_boxes.push(HitBox {
rect,
module: module.to_owned(),
segment: Some(id.to_owned()),
});
}
y += 30.0;
self.draw_calendar(
canvas,
physical_width,
physical_height,
scale,
module,
calendar,
Rect {
x: padding,
y,
width: logical_width - padding * 2.0,
height: (logical_height - y - padding).max(0.0),
},
theme,
);
}
PopupContent::Slider(slider) => {
let rect = Rect {
x: padding,
y,
width: logical_width - padding * 2.0,
height: 52.0,
};
self.draw_popup_text(
canvas,
physical_width,
physical_height,
scale,
module,
&format!("{} {:.0}%", slider.label, slider.value),
if slider.muted { "disabled" } else { "normal" },
Rect {
height: 26.0,
..rect
},
theme,
);
let track = Rect {
x: rect.x + 8.0,
y: rect.y + 34.0,
width: (rect.width - 16.0).max(1.0),
height: 6.0,
};
fill_rect(
canvas,
physical_width,
physical_height,
physical_rect(track, scale),
theme.module.border,
);
let ratio = ((slider.value - slider.minimum) / (slider.maximum - slider.minimum))
.clamp(0.0, 1.0);
fill_rect(
canvas,
physical_width,
physical_height,
physical_rect(
Rect {
width: track.width * ratio,
..track
},
scale,
),
theme
.states
.get("focused")
.and_then(|style| style.background)
.unwrap_or(theme.module.foreground),
);
result.hit_boxes.push(HitBox {
rect,
module: module.to_owned(),
segment: Some("slider".to_owned()),
});
y += 56.0;
for row in &slider.detail_rows {
if y + 32.0 > logical_height - padding {
break;
}
let rect = Rect {
x: padding,
y,
width: logical_width - padding * 2.0,
height: 32.0,
};
self.draw_popup_text(
canvas,
physical_width,
physical_height,
scale,
module,
&row.label,
if row.enabled { &row.state } else { "disabled" },
rect,
theme,
);
if row.enabled {
result.hit_boxes.push(HitBox {
rect,
module: module.to_owned(),
segment: row.id.clone(),
});
}
y += 34.0;
}
}
}
result
}
#[allow(clippy::too_many_arguments)]
fn draw_popup_text(
&mut self,
canvas: &mut [u8],
physical_width: u32,
physical_height: u32,
scale: u32,
module: &str,
text: &str,
state: &str,
rect: Rect,
theme: &Theme,
) {
let style = theme.style_for(module, state, &crate::config::StyleOverride::default());
self.draw_segment(
canvas,
physical_width,
physical_height,
scale,
&RenderSegment {
module: module.to_owned(),
segment: None,
text: text.to_owned(),
icon: None,
style,
rect,
},
theme,
);
}
#[allow(clippy::too_many_arguments)]
fn draw_calendar(
&mut self,
canvas: &mut [u8],
physical_width: u32,
physical_height: u32,
scale: u32,
module: &str,
calendar: &crate::model::CalendarModel,
rect: Rect,
theme: &Theme,
) {
use chrono::{Datelike, NaiveDate};
let cell_width = rect.width / 7.0;
let cell_height = 28.0;
for (column, label) in ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
.into_iter()
.enumerate()
{
self.draw_popup_text(
canvas,
physical_width,
physical_height,
scale,
module,
label,
"disabled",
Rect {
x: rect.x + column as f64 * cell_width,
y: rect.y,
width: cell_width,
height: cell_height,
},
theme,
);
}
let Some(first) = NaiveDate::from_ymd_opt(calendar.year, calendar.month, 1) else {
return;
};
let next = if calendar.month == 12 {
NaiveDate::from_ymd_opt(calendar.year + 1, 1, 1)
} else {
NaiveDate::from_ymd_opt(calendar.year, calendar.month + 1, 1)
};
let Some(next) = next else {
return;
};
let days = (next - first).num_days() as u32;
let offset = first.weekday().num_days_from_monday();
for day in 1..=days {
let index = offset + day - 1;
let column = index % 7;
let row = index / 7 + 1;
self.draw_popup_text(
canvas,
physical_width,
physical_height,
scale,
module,
&day.to_string(),
if day == calendar.selected_day {
"focused"
} else {
"normal"
},
Rect {
x: rect.x + f64::from(column) * cell_width,
y: rect.y + f64::from(row) * cell_height,
width: cell_width,
height: cell_height,
},
theme,
);
}
}
#[allow(clippy::too_many_arguments)]
fn measure_group(
&mut self,
names: &[String],
output: &str,
logical_height: f64,
scale: u32,
config: &Config,
theme: &Theme,
modules: &ModuleStore,
hovered: Option<(&str, Option<&str>)>,
) -> Vec<RenderSegment> {
let mut segments = Vec::new();
for name in names {
let Some(definition) = config.modules.get(name) else {
continue;
};
if !definition.common().enabled {
continue;
}
let snapshot = modules
.get(name)
.cloned()
.unwrap_or_default()
.for_output(output);
if !snapshot.visible {
continue;
}
let mut style = definition.common().style.clone();
if let ModuleConfig::Tray(tray) = definition {
style.padding_x.get_or_insert(tray.spacing / 2);
}
self.measure_module(
name,
style,
snapshot,
logical_height,
scale,
theme,
hovered,
&mut segments,
);
}
segments
}
#[allow(clippy::too_many_arguments)]
fn measure_module(
&mut self,
name: &str,
local_style: crate::config::StyleOverride,
snapshot: ModuleSnapshot,
logical_height: f64,
scale: u32,
theme: &Theme,
hovered: Option<(&str, Option<&str>)>,
result: &mut Vec<RenderSegment>,
) {
for segment in snapshot.segments {
let state = if snapshot.stale {
"stale"
} else if hovered
.is_some_and(|(module, id)| module == name && id == segment.id.as_deref())
{
"hover"
} else {
&segment.state
};
let style = theme.style_for(name, state, &local_style);
let text_width = self.measure_text(&segment.text, logical_height, scale, theme);
let icon_width = segment.icon.as_ref().map_or(0.0, |icon| {
let maximum = (logical_height - 4.0).max(1.0);
let ratio = f64::from(icon.width) / f64::from(icon.height.max(1));
(maximum * ratio).min(maximum * 2.0)
+ if segment.text.is_empty() { 0.0 } else { 4.0 }
});
let width = (text_width
+ icon_width
+ f64::from(style.padding_x.saturating_mul(2))
+ f64::from(style.border_width.saturating_mul(2)))
.max(f64::from(style.min_width));
result.push(RenderSegment {
module: name.to_owned(),
segment: segment.id,
text: segment.text,
icon: segment.icon,
style,
rect: Rect {
x: 0.0,
y: 0.0,
width,
height: logical_height,
},
});
}
}
fn measure_text(&mut self, text: &str, logical_height: f64, scale: u32, theme: &Theme) -> f64 {
let key = TextWidthKey {
text: text.to_owned(),
logical_height_bits: logical_height.to_bits(),
scale,
font_family: theme.font.family.clone(),
font_size_bits: theme.font.size.to_bits(),
};
if let Some((_, width)) = self
.text_width_cache
.iter()
.find(|(candidate, _)| candidate == &key)
{
return *width;
}
let scale = scale as f32;
let line_height = logical_height as f32 * scale;
let metrics = Metrics::new(theme.font.size * scale, line_height);
let attrs = Attrs::new().family(Family::Name(&theme.font.family));
let mut buffer = Buffer::new(&mut self.font_system, metrics);
buffer.set_size(None, Some(line_height));
buffer.set_text(text, &attrs, Shaping::Advanced, None);
let width = buffer
.borrow_with(&mut self.font_system)
.layout_runs()
.map(|run| run.line_w)
.fold(0.0_f32, f32::max);
let width = f64::from(width.ceil()) / f64::from(scale);
push_bounded(&mut self.text_width_cache, (key, width), 256);
width
}
fn draw_segment(
&mut self,
canvas: &mut [u8],
physical_width: u32,
physical_height: u32,
scale: u32,
segment: &RenderSegment,
theme: &Theme,
) {
let rect = physical_rect(segment.rect, scale);
if rect.2 == 0 || rect.3 == 0 {
return;
}
let key = SegmentCacheKey {
width: rect.2,
height: rect.3,
scale,
text: segment.text.clone(),
icon: segment.icon.clone(),
style: segment.style.clone(),
font_family: theme.font.family.clone(),
font_size_bits: theme.font.size.to_bits(),
};
if let Some((_, pixels)) = self
.segment_cache
.iter()
.find(|(candidate, _)| candidate == &key)
{
composite_surface(
canvas,
physical_width,
physical_height,
rect.0,
rect.1,
rect.2,
rect.3,
pixels,
);
return;
}
let mut pixels = vec![0_u8; rect.2 as usize * rect.3 as usize * 4];
let mut local = segment.clone();
local.rect.x = 0.0;
local.rect.y = 0.0;
self.draw_segment_uncached(&mut pixels, rect.2, rect.3, scale, &local, theme);
composite_surface(
canvas,
physical_width,
physical_height,
rect.0,
rect.1,
rect.2,
rect.3,
&pixels,
);
push_bounded(&mut self.segment_cache, (key, pixels), 128);
}
fn draw_segment_uncached(
&mut self,
canvas: &mut [u8],
physical_width: u32,
physical_height: u32,
scale: u32,
segment: &RenderSegment,
theme: &Theme,
) {
let rect = physical_rect(segment.rect, scale);
fill_rect(
canvas,
physical_width,
physical_height,
rect,
segment.style.background,
);
draw_border(
canvas,
physical_width,
physical_height,
rect,
segment.style.border,
segment.style.border_width.saturating_mul(scale),
);
let inset = (segment.style.padding_x + segment.style.border_width).saturating_mul(scale);
let mut text_x = rect.0.saturating_add(inset);
if let Some(icon) = &segment.icon {
let target_height = rect.3.saturating_sub(4_u32.saturating_mul(scale)).max(1);
let target_width = ((u64::from(target_height) * u64::from(icon.width))
/ u64::from(icon.height.max(1)))
.min(u64::from(target_height) * 2) as u32;
let icon_y = rect
.1
.saturating_add((rect.3.saturating_sub(target_height)) / 2);
draw_pixmap(
canvas,
physical_width,
physical_height,
text_x,
icon_y,
target_width.max(1),
target_height,
icon,
);
text_x =
text_x
.saturating_add(target_width)
.saturating_add(if segment.text.is_empty() {
0
} else {
4 * scale
});
}
let line_height = segment.rect.height as f32 * scale as f32;
let metrics = Metrics::new(theme.font.size * scale as f32, line_height);
let attrs = Attrs::new().family(Family::Name(&theme.font.family));
let mut buffer = Buffer::new(&mut self.font_system, metrics);
buffer.set_size(
Some((rect.2.saturating_sub(inset.saturating_mul(2))) as f32),
Some(line_height),
);
buffer.set_text(&segment.text, &attrs, Shaping::Advanced, None);
let color = Color::rgba(
segment.style.foreground.red,
segment.style.foreground.green,
segment.style.foreground.blue,
segment.style.foreground.alpha,
);
let mut borrowed = buffer.borrow_with(&mut self.font_system);
borrowed.draw(
&mut self.swash_cache,
color,
|x, y, width, height, glyph| {
let origin_x = i64::from(text_x) + i64::from(x);
let origin_y = i64::from(rect.1) + i64::from(y);
blend_glyph(
canvas,
physical_width,
physical_height,
origin_x,
origin_y,
width,
height,
glyph,
);
},
);
}
}
fn push_bounded<T>(cache: &mut VecDeque<T>, value: T, maximum: usize) {
if cache.len() == maximum {
cache.pop_front();
}
cache.push_back(value);
}
#[allow(clippy::too_many_arguments)]
fn composite_surface(
canvas: &mut [u8],
canvas_width: u32,
canvas_height: u32,
target_x: u32,
target_y: u32,
source_width: u32,
source_height: u32,
source: &[u8],
) {
if source.len() != source_width as usize * source_height as usize * 4 {
return;
}
for row in 0..source_height {
let y = target_y.saturating_add(row);
if y >= canvas_height {
break;
}
for column in 0..source_width {
let x = target_x.saturating_add(column);
if x >= canvas_width {
break;
}
let source_index = ((row as usize * source_width as usize) + column as usize) * 4;
let destination_index = ((y as usize * canvas_width as usize) + x as usize) * 4;
if let (Some(pixel), Some(destination)) = (
source.get(source_index..source_index + 4),
canvas.get_mut(destination_index..destination_index + 4),
) && pixel[3] != 0
{
blend_pixel(destination, [pixel[0], pixel[1], pixel[2], pixel[3]]);
}
}
}
}
fn group_width(group: &[RenderSegment]) -> f64 {
group
.iter()
.map(|segment| segment.rect.width + f64::from(segment.style.margin_x.saturating_mul(2)))
.sum()
}
fn position_group(group: &mut [RenderSegment], mut x: f64, height: f64) {
for segment in group {
x += f64::from(segment.style.margin_x);
segment.rect.x = x;
segment.rect.y = 0.0;
segment.rect.height = height;
x += segment.rect.width + f64::from(segment.style.margin_x);
}
}
fn physical_rect(rect: Rect, scale: u32) -> (u32, u32, u32, u32) {
let scale = f64::from(scale);
(
(rect.x * scale).max(0.0).round() as u32,
(rect.y * scale).max(0.0).round() as u32,
(rect.width * scale).max(0.0).round() as u32,
(rect.height * scale).max(0.0).round() as u32,
)
}
fn fill(canvas: &mut [u8], width: u32, height: u32, color: Rgba) {
fill_rect(canvas, width, height, (0, 0, width, height), color);
}
fn fill_rect(
canvas: &mut [u8],
canvas_width: u32,
canvas_height: u32,
rect: (u32, u32, u32, u32),
color: Rgba,
) {
let (x, y, width, height) = rect;
let x_end = x.saturating_add(width).min(canvas_width);
let y_end = y.saturating_add(height).min(canvas_height);
let source = color.as_premultiplied_bgra();
for row in y.min(canvas_height)..y_end {
for column in x.min(canvas_width)..x_end {
let index = ((row as usize * canvas_width as usize) + column as usize) * 4;
if let Some(pixel) = canvas.get_mut(index..index + 4) {
blend_pixel(pixel, source);
}
}
}
}
fn draw_border(
canvas: &mut [u8],
canvas_width: u32,
canvas_height: u32,
rect: (u32, u32, u32, u32),
color: Rgba,
width: u32,
) {
if width == 0 || rect.2 == 0 || rect.3 == 0 {
return;
}
let width = width.min(rect.2 / 2).min(rect.3 / 2);
fill_rect(
canvas,
canvas_width,
canvas_height,
(rect.0, rect.1, rect.2, width),
color,
);
fill_rect(
canvas,
canvas_width,
canvas_height,
(rect.0, rect.1 + rect.3 - width, rect.2, width),
color,
);
fill_rect(
canvas,
canvas_width,
canvas_height,
(rect.0, rect.1, width, rect.3),
color,
);
fill_rect(
canvas,
canvas_width,
canvas_height,
(rect.0 + rect.2 - width, rect.1, width, rect.3),
color,
);
}
#[allow(clippy::too_many_arguments)]
fn blend_glyph(
canvas: &mut [u8],
canvas_width: u32,
canvas_height: u32,
origin_x: i64,
origin_y: i64,
width: u32,
height: u32,
color: Color,
) {
if origin_x >= i64::from(canvas_width) || origin_y >= i64::from(canvas_height) {
return;
}
let source = Rgba::rgba(color.r(), color.g(), color.b(), color.a()).as_premultiplied_bgra();
for dy in 0..height {
let y = origin_y + i64::from(dy);
if !(0..i64::from(canvas_height)).contains(&y) {
continue;
}
for dx in 0..width {
let x = origin_x + i64::from(dx);
if !(0..i64::from(canvas_width)).contains(&x) {
continue;
}
let index = ((y as usize * canvas_width as usize) + x as usize) * 4;
if let Some(pixel) = canvas.get_mut(index..index + 4) {
blend_pixel(pixel, source);
}
}
}
}
fn blend_pixel(destination: &mut [u8], source: [u8; 4]) {
let inverse = 255_u16.saturating_sub(u16::from(source[3]));
for channel in 0..3 {
destination[channel] = u8::try_from(
u16::from(source[channel]) + (u16::from(destination[channel]) * inverse + 127) / 255,
)
.unwrap_or(u8::MAX);
}
destination[3] =
u8::try_from(u16::from(source[3]) + (u16::from(destination[3]) * inverse + 127) / 255)
.unwrap_or(u8::MAX);
}
#[allow(clippy::too_many_arguments)]
fn draw_pixmap(
canvas: &mut [u8],
canvas_width: u32,
canvas_height: u32,
target_x: u32,
target_y: u32,
target_width: u32,
target_height: u32,
pixmap: &IconPixmap,
) {
if pixmap.width == 0
|| pixmap.height == 0
|| pixmap.pixels.len() != pixmap.width as usize * pixmap.height as usize * 4
{
return;
}
for dy in 0..target_height {
let y = target_y.saturating_add(dy);
if y >= canvas_height {
break;
}
let source_y =
(u64::from(dy) * u64::from(pixmap.height) / u64::from(target_height.max(1))) as u32;
for dx in 0..target_width {
let x = target_x.saturating_add(dx);
if x >= canvas_width {
break;
}
let source_x =
(u64::from(dx) * u64::from(pixmap.width) / u64::from(target_width.max(1))) as u32;
let source_index =
((source_y as usize * pixmap.width as usize) + source_x as usize) * 4;
let destination_index = ((y as usize * canvas_width as usize) + x as usize) * 4;
if let (Some(source), Some(destination)) = (
pixmap.pixels.get(source_index..source_index + 4),
canvas.get_mut(destination_index..destination_index + 4),
) {
blend_pixel(destination, [source[0], source[1], source[2], source[3]]);
}
}
}
}
pub fn hit_test(hit_boxes: &[HitBox], x: f64, y: f64) -> Option<&HitBox> {
hit_boxes
.iter()
.rev()
.find(|hit_box| hit_box.rect.contains(x, y))
}
pub fn snapshots_for_test(values: &[(&str, &str)]) -> ModuleStore {
let mut store = ModuleStore::default();
for (name, text) in values {
store.insert(*name, ModuleSnapshot::text(*text));
}
store
}
pub fn state_counts(store: &ModuleStore, names: &[String]) -> BTreeMap<String, usize> {
names
.iter()
.map(|name| {
let count = store
.get(name)
.map_or(0, |snapshot| snapshot.segments.len());
(name.clone(), count)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hit_testing_prefers_last_overlapping_box() {
let boxes = vec![
HitBox {
rect: Rect {
x: 0.0,
y: 0.0,
width: 20.0,
height: 20.0,
},
module: "first".to_owned(),
segment: None,
},
HitBox {
rect: Rect {
x: 10.0,
y: 0.0,
width: 20.0,
height: 20.0,
},
module: "second".to_owned(),
segment: None,
},
];
assert_eq!(hit_test(&boxes, 15.0, 5.0).unwrap().module, "second");
assert!(hit_test(&boxes, 50.0, 5.0).is_none());
}
#[test]
fn alpha_blend_is_premultiplied() {
let mut destination = [0_u8, 0, 0, 0];
blend_pixel(
&mut destination,
Rgba::rgba(255, 0, 0, 128).as_premultiplied_bgra(),
);
assert_eq!(destination, [0, 0, 128, 128]);
}
}