use std::sync::Arc;
use crate::color::Color;
use crate::draw_ctx::DrawCtx;
use crate::geometry::{Point, Rect, Size};
use crate::layout_props::Insets;
use crate::text::Font;
#[derive(Clone)]
pub struct CardStyle {
pub title_size: f64,
pub detail_size: f64,
pub pad_x: f64,
pub pad_y: f64,
pub line_gap: f64,
pub corner_radius: f64,
pub margin: f64,
pub anchor_clearance: f64,
pub bg: Color,
pub border_color: Color,
pub border_width: f64,
pub title_color: Color,
pub detail_color: Color,
}
impl Default for CardStyle {
fn default() -> Self {
Self {
title_size: 14.0,
detail_size: 11.0,
pad_x: 12.0,
pad_y: 9.0,
line_gap: 4.0,
corner_radius: 7.0,
margin: 8.0,
anchor_clearance: 6.0,
bg: Color::from_rgba8(15, 20, 38, 230),
border_color: Color::from_rgba8(120, 140, 180, 180),
border_width: 1.0,
title_color: Color::from_rgb8(235, 238, 250),
detail_color: Color::from_rgb8(200, 205, 225),
}
}
}
pub fn measure(
ctx: &mut dyn DrawCtx,
font: Arc<Font>,
style: &CardStyle,
title: &str,
details: &[String],
) -> Size {
ctx.set_font(font);
let text_w = |ctx: &mut dyn DrawCtx, s: &str, size: f64| -> f64 {
ctx.set_font_size(size);
ctx.measure_text(s)
.map(|m| m.width)
.unwrap_or_else(|| s.chars().count() as f64 * size * 0.6)
};
let mut w = text_w(ctx, title, style.title_size);
for d in details {
w = w.max(text_w(ctx, d, style.detail_size));
}
let detail_block_h = if details.is_empty() {
0.0
} else {
details.len() as f64 * style.detail_size
+ (details.len() as f64 - 1.0) * style.line_gap
};
Size::new(
w + style.pad_x * 2.0,
style.title_size + style.line_gap + detail_block_h + style.pad_y * 2.0,
)
}
pub fn anchored_rect(container: Size, anchor: Point, size: Size, style: &CardStyle) -> Rect {
anchored_rect_with_insets(container, anchor, size, style, crate::overlay_insets::current())
}
pub fn anchored_rect_with_insets(
container: Size,
anchor: Point,
size: Size,
style: &CardStyle,
insets: Insets,
) -> Rect {
let m = style.margin;
let safe_l = insets.left + m;
let safe_r = container.width - insets.right - m;
let safe_b = insets.bottom + m;
let safe_t = container.height - insets.top - m;
let x = if size.width >= safe_r - safe_l {
safe_l + (safe_r - safe_l - size.width) / 2.0
} else {
(anchor.x - size.width / 2.0).clamp(safe_l, safe_r - size.width)
};
let clearance = style.anchor_clearance;
let below_y = anchor.y - clearance - size.height;
let above_y = anchor.y + clearance;
let y = if size.height >= safe_t - safe_b {
safe_b + (safe_t - safe_b - size.height) / 2.0
} else if below_y >= safe_b {
below_y
} else if above_y + size.height <= safe_t {
above_y
} else {
let below_room = anchor.y - safe_b;
let above_room = safe_t - anchor.y;
if below_room >= above_room {
safe_b
} else {
safe_t - size.height
}
};
Rect::new(x, y, size.width, size.height)
}
pub fn paint(
ctx: &mut dyn DrawCtx,
font: Arc<Font>,
style: &CardStyle,
rect: Rect,
title: &str,
details: &[String],
) {
ctx.set_fill_color(style.bg);
ctx.begin_path();
ctx.rounded_rect(rect.x, rect.y, rect.width, rect.height, style.corner_radius);
ctx.fill();
if style.border_width > 0.0 {
ctx.set_stroke_color(style.border_color);
ctx.set_line_width(style.border_width);
ctx.begin_path();
ctx.rounded_rect(rect.x, rect.y, rect.width, rect.height, style.corner_radius);
ctx.stroke();
}
ctx.set_font(font);
ctx.set_fill_color(style.title_color);
ctx.set_font_size(style.title_size);
let title_baseline = rect.y + rect.height - style.pad_y - style.title_size * 0.75;
ctx.fill_text(title, rect.x + style.pad_x, title_baseline);
ctx.set_fill_color(style.detail_color);
ctx.set_font_size(style.detail_size);
for (i, line) in details.iter().enumerate() {
let dy = (i as f64) * (style.detail_size + style.line_gap);
let baseline = title_baseline - style.title_size - style.line_gap - dy
+ (style.title_size - style.detail_size) * 0.25;
ctx.fill_text(line, rect.x + style.pad_x, baseline);
}
}
fn wrap_with(text_width: &dyn Fn(&str) -> f64, line: &str, max_w: f64) -> Vec<String> {
if text_width(line) <= max_w {
return vec![line.to_string()];
}
let mut out: Vec<String> = Vec::new();
let mut current = String::new();
for word in line.split_whitespace() {
let candidate = if current.is_empty() {
word.to_string()
} else {
format!("{current} {word}")
};
if !current.is_empty() && text_width(&candidate) > max_w {
out.push(std::mem::take(&mut current));
current = word.to_string();
} else {
current = candidate;
}
}
if !current.is_empty() {
out.push(current);
}
if out.is_empty() {
out.push(line.to_string());
}
out
}
fn wrap_details(
ctx: &mut dyn DrawCtx,
font: Arc<Font>,
style: &CardStyle,
details: &[String],
max_card_w: f64,
) -> Vec<String> {
ctx.set_font(font);
ctx.set_font_size(style.detail_size);
let max_text_w = (max_card_w - style.pad_x * 2.0).max(1.0);
let size = style.detail_size;
let width = |s: &str| -> f64 {
ctx.measure_text(s)
.map(|m| m.width)
.unwrap_or_else(|| s.chars().count() as f64 * size * 0.6)
};
let mut wrapped = Vec::new();
for line in details {
wrapped.extend(wrap_with(&width, line, max_text_w));
}
wrapped
}
pub fn paint_anchored(
ctx: &mut dyn DrawCtx,
font: Arc<Font>,
style: &CardStyle,
container: Size,
anchor: Point,
extra_insets: Insets,
title: &str,
details: &[String],
) -> Rect {
let frame = crate::overlay_insets::for_paint_ctx(ctx, container);
let insets = Insets {
left: frame.left.max(extra_insets.left),
right: frame.right.max(extra_insets.right),
top: frame.top.max(extra_insets.top),
bottom: frame.bottom.max(extra_insets.bottom),
};
let safe_w = container.width - insets.left - insets.right - style.margin * 2.0;
let details = wrap_details(ctx, Arc::clone(&font), style, details, safe_w);
let size = measure(ctx, Arc::clone(&font), style, title, &details);
let rect = anchored_rect_with_insets(container, anchor, size, style, insets);
paint(ctx, font, style, rect, title, &details);
rect
}
#[cfg(test)]
mod tests {
use super::*;
fn style() -> CardStyle {
CardStyle {
margin: 8.0,
anchor_clearance: 6.0,
..CardStyle::default()
}
}
const CONTAINER: Size = Size {
width: 240.0,
height: 500.0,
};
const CARD: Size = Size {
width: 180.0,
height: 60.0,
};
#[test]
fn sits_below_anchor_with_room() {
let r = anchored_rect_with_insets(
CONTAINER,
Point { x: 120.0, y: 300.0 },
CARD,
&style(),
Insets::default(),
);
assert_eq!(r.y, 300.0 - 6.0 - 60.0);
assert_eq!(r.x, 120.0 - 90.0, "centred on the anchor");
}
#[test]
fn flips_above_when_bottom_is_reserved() {
let ins = Insets {
bottom: 260.0,
..Insets::default()
};
let r = anchored_rect_with_insets(
CONTAINER,
Point { x: 120.0, y: 300.0 },
CARD,
&style(),
ins,
);
assert_eq!(r.y, 300.0 + 6.0, "flipped fully above the anchor");
assert!(r.y >= 260.0 + 8.0, "clear of the reserved strip");
}
#[test]
fn left_rail_reservation_pushes_card_right() {
let ins = Insets {
left: 56.0,
..Insets::default()
};
let r = anchored_rect_with_insets(
CONTAINER,
Point { x: 10.0, y: 300.0 },
Size {
width: 120.0,
height: 60.0,
},
&style(),
ins,
);
assert_eq!(r.x, 56.0 + 8.0, "left edge clears rail + margin");
}
#[test]
fn wider_than_safe_area_overflows_symmetrically() {
let r = anchored_rect_with_insets(
Size {
width: 150.0,
height: 500.0,
},
Point { x: 20.0, y: 300.0 },
CARD, &style(),
Insets::default(),
);
let overflow_left = 8.0 - r.x;
let overflow_right = (r.x + 180.0) - 142.0;
assert!(
(overflow_left - overflow_right).abs() < 1e-9,
"overflow must be symmetric: {overflow_left} vs {overflow_right}"
);
}
#[test]
fn wrap_splits_long_lines_on_word_boundaries() {
let w = |s: &str| s.chars().count() as f64 * 6.0;
assert_eq!(
wrap_with(&w, "Rises 11:34pm · Sets 1:04pm", 200.0),
vec!["Rises 11:34pm · Sets 1:04pm"],
"no wrapping when the line already fits"
);
let wrapped = wrap_with(&w, "Rises 11:34pm · Sets 1:04pm", 100.0);
assert_eq!(wrapped, vec!["Rises 11:34pm ·", "Sets 1:04pm"]);
for line in &wrapped {
assert!(w(line) <= 100.0, "every wrapped line fits: {line}");
}
assert_eq!(
wrap_with(&w, "Circumpolar", 30.0),
vec!["Circumpolar"]
);
}
#[test]
fn no_room_either_side_pins_inside_safe_area() {
let ins = Insets {
top: 100.0,
bottom: 40.0,
..Insets::default()
};
let r = anchored_rect_with_insets(
CONTAINER,
Point { x: 120.0, y: 70.0 },
Size {
width: 180.0,
height: 330.0,
},
&style(),
ins,
);
assert!(r.y >= 40.0 + 8.0 - 1e-9, "stays above the bottom strip");
assert!(
r.y + 330.0 <= 500.0 - 100.0 - 8.0 + 1e-9,
"stays below the top strip"
);
}
}