1use std::sync::Arc;
30
31use crate::color::Color;
32use crate::draw_ctx::DrawCtx;
33use crate::geometry::{Point, Rect, Size};
34use crate::layout_props::Insets;
35use crate::text::Font;
36
37#[derive(Clone)]
40pub struct CardStyle {
41 pub title_size: f64,
42 pub detail_size: f64,
43 pub pad_x: f64,
44 pub pad_y: f64,
45 pub line_gap: f64,
46 pub corner_radius: f64,
47 pub margin: f64,
50 pub anchor_clearance: f64,
52 pub bg: Color,
53 pub border_color: Color,
54 pub border_width: f64,
55 pub title_color: Color,
56 pub detail_color: Color,
57}
58
59impl Default for CardStyle {
60 fn default() -> Self {
61 Self {
62 title_size: 14.0,
63 detail_size: 11.0,
64 pad_x: 12.0,
65 pad_y: 9.0,
66 line_gap: 4.0,
67 corner_radius: 7.0,
68 margin: 8.0,
69 anchor_clearance: 6.0,
70 bg: Color::from_rgba8(15, 20, 38, 230),
71 border_color: Color::from_rgba8(120, 140, 180, 180),
72 border_width: 1.0,
73 title_color: Color::from_rgb8(235, 238, 250),
74 detail_color: Color::from_rgb8(200, 205, 225),
75 }
76 }
77}
78
79pub fn measure(
83 ctx: &mut dyn DrawCtx,
84 font: Arc<Font>,
85 style: &CardStyle,
86 title: &str,
87 details: &[String],
88) -> Size {
89 ctx.set_font(font);
90 let text_w = |ctx: &mut dyn DrawCtx, s: &str, size: f64| -> f64 {
91 ctx.set_font_size(size);
92 ctx.measure_text(s)
93 .map(|m| m.width)
94 .unwrap_or_else(|| s.chars().count() as f64 * size * 0.6)
95 };
96
97 let mut w = text_w(ctx, title, style.title_size);
98 for d in details {
99 w = w.max(text_w(ctx, d, style.detail_size));
100 }
101
102 let detail_block_h = if details.is_empty() {
103 0.0
104 } else {
105 details.len() as f64 * style.detail_size
106 + (details.len() as f64 - 1.0) * style.line_gap
107 };
108 Size::new(
109 w + style.pad_x * 2.0,
110 style.title_size + style.line_gap + detail_block_h + style.pad_y * 2.0,
111 )
112}
113
114pub fn anchored_rect(container: Size, anchor: Point, size: Size, style: &CardStyle) -> Rect {
117 anchored_rect_with_insets(container, anchor, size, style, crate::overlay_insets::current())
118}
119
120pub fn anchored_rect_with_insets(
128 container: Size,
129 anchor: Point,
130 size: Size,
131 style: &CardStyle,
132 insets: Insets,
133) -> Rect {
134 let m = style.margin;
135 let safe_l = insets.left + m;
136 let safe_r = container.width - insets.right - m;
137 let safe_b = insets.bottom + m;
138 let safe_t = container.height - insets.top - m;
139
140 let x = if size.width >= safe_r - safe_l {
141 safe_l + (safe_r - safe_l - size.width) / 2.0
142 } else {
143 (anchor.x - size.width / 2.0).clamp(safe_l, safe_r - size.width)
144 };
145
146 let clearance = style.anchor_clearance;
147 let below_y = anchor.y - clearance - size.height;
148 let above_y = anchor.y + clearance;
149 let y = if size.height >= safe_t - safe_b {
150 safe_b + (safe_t - safe_b - size.height) / 2.0
151 } else if below_y >= safe_b {
152 below_y
153 } else if above_y + size.height <= safe_t {
154 above_y
155 } else {
156 let below_room = anchor.y - safe_b;
159 let above_room = safe_t - anchor.y;
160 if below_room >= above_room {
161 safe_b
162 } else {
163 safe_t - size.height
164 }
165 };
166
167 Rect::new(x, y, size.width, size.height)
168}
169
170pub fn paint(
173 ctx: &mut dyn DrawCtx,
174 font: Arc<Font>,
175 style: &CardStyle,
176 rect: Rect,
177 title: &str,
178 details: &[String],
179) {
180 ctx.set_fill_color(style.bg);
181 ctx.begin_path();
182 ctx.rounded_rect(rect.x, rect.y, rect.width, rect.height, style.corner_radius);
183 ctx.fill();
184 if style.border_width > 0.0 {
185 ctx.set_stroke_color(style.border_color);
186 ctx.set_line_width(style.border_width);
187 ctx.begin_path();
188 ctx.rounded_rect(rect.x, rect.y, rect.width, rect.height, style.corner_radius);
189 ctx.stroke();
190 }
191
192 ctx.set_font(font);
195 ctx.set_fill_color(style.title_color);
196 ctx.set_font_size(style.title_size);
197 let title_baseline = rect.y + rect.height - style.pad_y - style.title_size * 0.75;
198 ctx.fill_text(title, rect.x + style.pad_x, title_baseline);
199
200 ctx.set_fill_color(style.detail_color);
201 ctx.set_font_size(style.detail_size);
202 for (i, line) in details.iter().enumerate() {
203 let dy = (i as f64) * (style.detail_size + style.line_gap);
204 let baseline = title_baseline - style.title_size - style.line_gap - dy
205 + (style.title_size - style.detail_size) * 0.25;
206 ctx.fill_text(line, rect.x + style.pad_x, baseline);
207 }
208}
209
210fn wrap_with(text_width: &dyn Fn(&str) -> f64, line: &str, max_w: f64) -> Vec<String> {
215 if text_width(line) <= max_w {
216 return vec![line.to_string()];
217 }
218 let mut out: Vec<String> = Vec::new();
219 let mut current = String::new();
220 for word in line.split_whitespace() {
221 let candidate = if current.is_empty() {
222 word.to_string()
223 } else {
224 format!("{current} {word}")
225 };
226 if !current.is_empty() && text_width(&candidate) > max_w {
227 out.push(std::mem::take(&mut current));
228 current = word.to_string();
229 } else {
230 current = candidate;
231 }
232 }
233 if !current.is_empty() {
234 out.push(current);
235 }
236 if out.is_empty() {
237 out.push(line.to_string());
238 }
239 out
240}
241
242fn wrap_details(
245 ctx: &mut dyn DrawCtx,
246 font: Arc<Font>,
247 style: &CardStyle,
248 details: &[String],
249 max_card_w: f64,
250) -> Vec<String> {
251 ctx.set_font(font);
252 ctx.set_font_size(style.detail_size);
253 let max_text_w = (max_card_w - style.pad_x * 2.0).max(1.0);
254 let size = style.detail_size;
255 let width = |s: &str| -> f64 {
256 ctx.measure_text(s)
257 .map(|m| m.width)
258 .unwrap_or_else(|| s.chars().count() as f64 * size * 0.6)
259 };
260 let mut wrapped = Vec::new();
261 for line in details {
262 wrapped.extend(wrap_with(&width, line, max_text_w));
263 }
264 wrapped
265}
266
267pub fn paint_anchored(
282 ctx: &mut dyn DrawCtx,
283 font: Arc<Font>,
284 style: &CardStyle,
285 container: Size,
286 anchor: Point,
287 extra_insets: Insets,
288 title: &str,
289 details: &[String],
290) -> Rect {
291 let frame = crate::overlay_insets::for_paint_ctx(ctx, container);
292 let insets = Insets {
293 left: frame.left.max(extra_insets.left),
294 right: frame.right.max(extra_insets.right),
295 top: frame.top.max(extra_insets.top),
296 bottom: frame.bottom.max(extra_insets.bottom),
297 };
298 let safe_w = container.width - insets.left - insets.right - style.margin * 2.0;
299 let details = wrap_details(ctx, Arc::clone(&font), style, details, safe_w);
300 let size = measure(ctx, Arc::clone(&font), style, title, &details);
301 let rect = anchored_rect_with_insets(container, anchor, size, style, insets);
302 paint(ctx, font, style, rect, title, &details);
303 rect
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 fn style() -> CardStyle {
311 CardStyle {
312 margin: 8.0,
313 anchor_clearance: 6.0,
314 ..CardStyle::default()
315 }
316 }
317
318 const CONTAINER: Size = Size {
319 width: 240.0,
320 height: 500.0,
321 };
322 const CARD: Size = Size {
323 width: 180.0,
324 height: 60.0,
325 };
326
327 #[test]
328 fn sits_below_anchor_with_room() {
329 let r = anchored_rect_with_insets(
330 CONTAINER,
331 Point { x: 120.0, y: 300.0 },
332 CARD,
333 &style(),
334 Insets::default(),
335 );
336 assert_eq!(r.y, 300.0 - 6.0 - 60.0);
337 assert_eq!(r.x, 120.0 - 90.0, "centred on the anchor");
338 }
339
340 #[test]
341 fn flips_above_when_bottom_is_reserved() {
342 let ins = Insets {
344 bottom: 260.0,
345 ..Insets::default()
346 };
347 let r = anchored_rect_with_insets(
348 CONTAINER,
349 Point { x: 120.0, y: 300.0 },
350 CARD,
351 &style(),
352 ins,
353 );
354 assert_eq!(r.y, 300.0 + 6.0, "flipped fully above the anchor");
355 assert!(r.y >= 260.0 + 8.0, "clear of the reserved strip");
356 }
357
358 #[test]
359 fn left_rail_reservation_pushes_card_right() {
360 let ins = Insets {
361 left: 56.0,
362 ..Insets::default()
363 };
364 let r = anchored_rect_with_insets(
365 CONTAINER,
366 Point { x: 10.0, y: 300.0 },
367 Size {
368 width: 120.0,
369 height: 60.0,
370 },
371 &style(),
372 ins,
373 );
374 assert_eq!(r.x, 56.0 + 8.0, "left edge clears rail + margin");
375 }
376
377 #[test]
378 fn wider_than_safe_area_overflows_symmetrically() {
379 let r = anchored_rect_with_insets(
380 Size {
381 width: 150.0,
382 height: 500.0,
383 },
384 Point { x: 20.0, y: 300.0 },
385 CARD, &style(),
387 Insets::default(),
388 );
389 let overflow_left = 8.0 - r.x;
390 let overflow_right = (r.x + 180.0) - 142.0;
391 assert!(
392 (overflow_left - overflow_right).abs() < 1e-9,
393 "overflow must be symmetric: {overflow_left} vs {overflow_right}"
394 );
395 }
396
397 #[test]
398 fn wrap_splits_long_lines_on_word_boundaries() {
399 let w = |s: &str| s.chars().count() as f64 * 6.0;
401
402 assert_eq!(
403 wrap_with(&w, "Rises 11:34pm · Sets 1:04pm", 200.0),
404 vec!["Rises 11:34pm · Sets 1:04pm"],
405 "no wrapping when the line already fits"
406 );
407
408 let wrapped = wrap_with(&w, "Rises 11:34pm · Sets 1:04pm", 100.0);
410 assert_eq!(wrapped, vec!["Rises 11:34pm ·", "Sets 1:04pm"]);
411 for line in &wrapped {
412 assert!(w(line) <= 100.0, "every wrapped line fits: {line}");
413 }
414
415 assert_eq!(
417 wrap_with(&w, "Circumpolar", 30.0),
418 vec!["Circumpolar"]
419 );
420 }
421
422 #[test]
423 fn no_room_either_side_pins_inside_safe_area() {
424 let ins = Insets {
426 top: 100.0,
427 bottom: 40.0,
428 ..Insets::default()
429 };
430 let r = anchored_rect_with_insets(
431 CONTAINER,
432 Point { x: 120.0, y: 70.0 },
433 Size {
434 width: 180.0,
435 height: 330.0,
436 },
437 &style(),
438 ins,
439 );
440 assert!(r.y >= 40.0 + 8.0 - 1e-9, "stays above the bottom strip");
441 assert!(
442 r.y + 330.0 <= 500.0 - 100.0 - 8.0 + 1e-9,
443 "stays below the top strip"
444 );
445 }
446}