1202 lines
38 KiB
Rust
1202 lines
38 KiB
Rust
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]);
|
||
}
|
||
}
|