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 + (details.len() as f64 - 1.0) * style.line_gap
106 };
107 Size::new(
108 w + style.pad_x * 2.0,
109 style.title_size + style.line_gap + detail_block_h + style.pad_y * 2.0,
110 )
111}
112
113pub fn anchored_rect(container: Size, anchor: Point, size: Size, style: &CardStyle) -> Rect {
116 anchored_rect_with_insets(
117 container,
118 anchor,
119 size,
120 style,
121 crate::overlay_insets::current(),
122 )
123}
124
125pub fn anchored_rect_with_insets(
133 container: Size,
134 anchor: Point,
135 size: Size,
136 style: &CardStyle,
137 insets: Insets,
138) -> Rect {
139 let m = style.margin;
140 let safe_l = insets.left + m;
141 let safe_r = container.width - insets.right - m;
142 let safe_b = insets.bottom + m;
143 let safe_t = container.height - insets.top - m;
144
145 let x = if size.width >= safe_r - safe_l {
146 safe_l + (safe_r - safe_l - size.width) / 2.0
147 } else {
148 (anchor.x - size.width / 2.0).clamp(safe_l, safe_r - size.width)
149 };
150
151 let clearance = style.anchor_clearance;
152 let below_y = anchor.y - clearance - size.height;
153 let above_y = anchor.y + clearance;
154 let y = if size.height >= safe_t - safe_b {
155 safe_b + (safe_t - safe_b - size.height) / 2.0
156 } else if below_y >= safe_b {
157 below_y
158 } else if above_y + size.height <= safe_t {
159 above_y
160 } else {
161 let below_room = anchor.y - safe_b;
164 let above_room = safe_t - anchor.y;
165 if below_room >= above_room {
166 safe_b
167 } else {
168 safe_t - size.height
169 }
170 };
171
172 Rect::new(x, y, size.width, size.height)
173}
174
175pub fn paint(
178 ctx: &mut dyn DrawCtx,
179 font: Arc<Font>,
180 style: &CardStyle,
181 rect: Rect,
182 title: &str,
183 details: &[String],
184) {
185 ctx.set_fill_color(style.bg);
186 ctx.begin_path();
187 ctx.rounded_rect(rect.x, rect.y, rect.width, rect.height, style.corner_radius);
188 ctx.fill();
189 if style.border_width > 0.0 {
190 ctx.set_stroke_color(style.border_color);
191 ctx.set_line_width(style.border_width);
192 ctx.begin_path();
193 ctx.rounded_rect(rect.x, rect.y, rect.width, rect.height, style.corner_radius);
194 ctx.stroke();
195 }
196
197 ctx.set_font(font);
200 ctx.set_fill_color(style.title_color);
201 ctx.set_font_size(style.title_size);
202 let title_baseline = rect.y + rect.height - style.pad_y - style.title_size * 0.75;
203 ctx.fill_text(title, rect.x + style.pad_x, title_baseline);
204
205 ctx.set_fill_color(style.detail_color);
206 ctx.set_font_size(style.detail_size);
207 for (i, line) in details.iter().enumerate() {
208 let dy = (i as f64) * (style.detail_size + style.line_gap);
209 let baseline = title_baseline - style.title_size - style.line_gap - dy
210 + (style.title_size - style.detail_size) * 0.25;
211 ctx.fill_text(line, rect.x + style.pad_x, baseline);
212 }
213}
214
215fn wrap_with(text_width: &dyn Fn(&str) -> f64, line: &str, max_w: f64) -> Vec<String> {
220 if text_width(line) <= max_w {
221 return vec![line.to_string()];
222 }
223 let mut out: Vec<String> = Vec::new();
224 let mut current = String::new();
225 for word in line.split_whitespace() {
226 let candidate = if current.is_empty() {
227 word.to_string()
228 } else {
229 format!("{current} {word}")
230 };
231 if !current.is_empty() && text_width(&candidate) > max_w {
232 out.push(std::mem::take(&mut current));
233 current = word.to_string();
234 } else {
235 current = candidate;
236 }
237 }
238 if !current.is_empty() {
239 out.push(current);
240 }
241 if out.is_empty() {
242 out.push(line.to_string());
243 }
244 out
245}
246
247fn wrap_details(
250 ctx: &mut dyn DrawCtx,
251 font: Arc<Font>,
252 style: &CardStyle,
253 details: &[String],
254 max_card_w: f64,
255) -> Vec<String> {
256 ctx.set_font(font);
257 ctx.set_font_size(style.detail_size);
258 let max_text_w = (max_card_w - style.pad_x * 2.0).max(1.0);
259 let size = style.detail_size;
260 let width = |s: &str| -> f64 {
261 ctx.measure_text(s)
262 .map(|m| m.width)
263 .unwrap_or_else(|| s.chars().count() as f64 * size * 0.6)
264 };
265 let mut wrapped = Vec::new();
266 for line in details {
267 wrapped.extend(wrap_with(&width, line, max_text_w));
268 }
269 wrapped
270}
271
272pub fn paint_anchored(
287 ctx: &mut dyn DrawCtx,
288 font: Arc<Font>,
289 style: &CardStyle,
290 container: Size,
291 anchor: Point,
292 extra_insets: Insets,
293 title: &str,
294 details: &[String],
295) -> Rect {
296 let frame = crate::overlay_insets::for_paint_ctx(ctx, container);
297 let insets = Insets {
298 left: frame.left.max(extra_insets.left),
299 right: frame.right.max(extra_insets.right),
300 top: frame.top.max(extra_insets.top),
301 bottom: frame.bottom.max(extra_insets.bottom),
302 };
303 let safe_w = container.width - insets.left - insets.right - style.margin * 2.0;
304 let details = wrap_details(ctx, Arc::clone(&font), style, details, safe_w);
305 let size = measure(ctx, Arc::clone(&font), style, title, &details);
306 let rect = anchored_rect_with_insets(container, anchor, size, style, insets);
307 paint(ctx, font, style, rect, title, &details);
308 rect
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 fn style() -> CardStyle {
316 CardStyle {
317 margin: 8.0,
318 anchor_clearance: 6.0,
319 ..CardStyle::default()
320 }
321 }
322
323 const CONTAINER: Size = Size {
324 width: 240.0,
325 height: 500.0,
326 };
327 const CARD: Size = Size {
328 width: 180.0,
329 height: 60.0,
330 };
331
332 #[test]
333 fn sits_below_anchor_with_room() {
334 let r = anchored_rect_with_insets(
335 CONTAINER,
336 Point { x: 120.0, y: 300.0 },
337 CARD,
338 &style(),
339 Insets::default(),
340 );
341 assert_eq!(r.y, 300.0 - 6.0 - 60.0);
342 assert_eq!(r.x, 120.0 - 90.0, "centred on the anchor");
343 }
344
345 #[test]
346 fn flips_above_when_bottom_is_reserved() {
347 let ins = Insets {
349 bottom: 260.0,
350 ..Insets::default()
351 };
352 let r =
353 anchored_rect_with_insets(CONTAINER, Point { x: 120.0, y: 300.0 }, CARD, &style(), ins);
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!(wrap_with(&w, "Circumpolar", 30.0), vec!["Circumpolar"]);
417 }
418
419 #[test]
420 fn no_room_either_side_pins_inside_safe_area() {
421 let ins = Insets {
423 top: 100.0,
424 bottom: 40.0,
425 ..Insets::default()
426 };
427 let r = anchored_rect_with_insets(
428 CONTAINER,
429 Point { x: 120.0, y: 70.0 },
430 Size {
431 width: 180.0,
432 height: 330.0,
433 },
434 &style(),
435 ins,
436 );
437 assert!(r.y >= 40.0 + 8.0 - 1e-9, "stays above the bottom strip");
438 assert!(
439 r.y + 330.0 <= 500.0 - 100.0 - 8.0 + 1e-9,
440 "stays below the top strip"
441 );
442 }
443}