//! `tahuantinsuyu-canvas` — el widget GPUI del lienzo astrológico. //! //! Modela el cielo como un lienzo de **geometría reactiva**: un estado //! unificado [`CanvasState`] guarda offsets de rotación, flags de //! visibilidad y la lista de `Layer`s a pintar. Las interacciones //! (drag, hotkeys, toggles) mutan el estado; el render lee la última //! `RenderModel` y la deriva al frame. //! //! ## Convención de rotación //! //! El Ascendente cae a las 9 del reloj (lado izquierdo). Las casas //! crecen contrarreloj visualmente. Para una longitud eclíptica `L` y //! un ascendente `asc`: //! //! ```text //! screen_angle_rad = π - (L - asc + view_rotation) · π/180 //! point = (cx + r·cos(θ), cy + r·sin(θ)) //! ``` //! //! ## Interacciones (fase 4) //! //! - **Drag en el aro exterior** (jog-dial perimetral): rota la rueda //! visualmente mientras dura el drag y, al soltar, traduce el delta //! angular a minutos (1° ≈ 4 min) y emite //! [`CanvasEvent::TimeOffsetChanged`]. El host (la app) recomputa la //! carta para el instante desplazado. //! - **Hotkeys**: `D`/`H`/`X`/`P` togglean SignDial/Houses/Aspects/ //! Bodies. Click sobre el wheel le da focus al widget. #![forbid(unsafe_code)] #![warn(rust_2018_idioms)] use std::collections::HashMap; use std::f32::consts::PI; use gpui::{ AppContext, Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, PathBuilder, Pixels, Point, Render, SharedString, Styled, Window, canvas, div, hsla, linear_color_stop, linear_gradient, point, prelude::*, px, }; use tahuantinsuyu_engine::{Geometry, Layer, LayerKind, OUTER_RING_MODULES, RenderModel}; use tahuantinsuyu_model::{ChartId, ContactId, GroupId}; use tahuantinsuyu_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet}; use yahweh_theme::Theme; // ===================================================================== // Eventos // ===================================================================== #[derive(Clone, Debug)] pub enum CanvasEvent { /// Doble click sobre un thumbnail. ChartRequested(ChartId), /// Drag terminado: el offset acumulado de tiempo (en minutos) /// cambió. El host debe recomputar el chart con este offset. TimeOffsetChanged(i64), /// El usuario togggleó una capa via hotkey — el panel debería /// reflejarlo si quisiera mantenerse en sync. LayerVisibilityChanged { kind: LayerKind, visible: bool }, /// El usuario pidió exportar el render actual como SVG. El shell /// se encarga de escribir el archivo (la engine genera el string). ExportSvgRequested, } // ===================================================================== // Estado // ===================================================================== #[derive(Clone, Debug, Default)] pub enum CanvasMode { #[default] Empty, Wheel { render: Box }, Thumbnails { scope: ThumbnailScope, items: Vec, }, } #[derive(Clone, Debug)] pub enum ThumbnailScope { Group(GroupId), Contact(ContactId), } #[derive(Clone, Debug)] pub struct ThumbnailItem { pub chart_id: ChartId, pub label: SharedString, pub subtitle: Option, pub preview: Option, } /// Estado de un drag activo del jog-dial. `last_screen_angle_deg` se /// actualiza en cada `MouseMoveEvent`; `accumulated_delta_deg` lleva la /// rotación total desde que arrancó el drag (puede pasar de ±360°). #[derive(Clone, Debug)] struct JogDragState { last_screen_angle_deg: f32, accumulated_delta_deg: f32, } #[derive(Clone, Debug)] pub struct CanvasState { pub mode: CanvasMode, /// Rotación visual transitoria durante un drag. Se resetea a `0` al /// soltar — el render nuevo trae el `ascendant_deg` ya rotado. pub view_rotation_deg: f32, /// Offset acumulado en minutos. Persiste entre drags hasta que el /// host lo resetee. pub time_offset_minutes: i64, /// Por-LayerKind: `true` = visible. Default = todo visible. pub layer_visibility: HashMap, /// Planeta hovered actualmente (para tooltip). `None` cuando el /// mouse no está sobre ningún cuerpo. pub hover: Option, drag_jog: Option, } /// Info del elemento bajo el cursor — usado por el render para mostrar /// un tooltip flotante con detalles. Cubre body glyphs (módulo + /// símbolo + grado + casa + retro + dignidad) y cusps de casa (número /// de la casa + grado del cusp + signo). #[derive(Clone, Debug)] pub enum HoverInfo { Body { module_id: String, symbol: String, deg: f32, house: Option, retrograde: bool, dignity_marker: Option, annotation: Option, local_x: f32, local_y: f32, }, HouseCusp { house_number: u8, deg: f32, local_x: f32, local_y: f32, }, } impl HoverInfo { fn local(&self) -> (f32, f32) { match self { HoverInfo::Body { local_x, local_y, .. } => (*local_x, *local_y), HoverInfo::HouseCusp { local_x, local_y, .. } => (*local_x, *local_y), } } fn key(&self) -> String { match self { HoverInfo::Body { module_id, symbol, .. } => format!("body:{}:{}", module_id, symbol), HoverInfo::HouseCusp { house_number, .. } => format!("cusp:{}", house_number), } } } impl Default for CanvasState { fn default() -> Self { Self { mode: CanvasMode::default(), view_rotation_deg: 0.0, time_offset_minutes: 0, layer_visibility: HashMap::new(), hover: None, drag_jog: None, } } } impl CanvasState { pub fn is_layer_visible(&self, kind: LayerKind) -> bool { self.layer_visibility.get(&kind).copied().unwrap_or(true) } } // ===================================================================== // Widget // ===================================================================== pub struct AstrologyCanvas { state: CanvasState, focus_handle: FocusHandle, } impl EventEmitter for AstrologyCanvas {} impl Focusable for AstrologyCanvas { fn focus_handle(&self, _: &gpui::App) -> FocusHandle { self.focus_handle.clone() } } impl AstrologyCanvas { pub fn new(cx: &mut Context) -> Self { cx.observe_global::(|_, cx| cx.notify()).detach(); Self { state: CanvasState::default(), focus_handle: cx.focus_handle(), } } pub fn state(&self) -> &CanvasState { &self.state } pub fn set_mode(&mut self, mode: CanvasMode, cx: &mut Context) { self.state.mode = mode; cx.notify(); } pub fn set_layer_visible(&mut self, kind: LayerKind, visible: bool, cx: &mut Context) { self.state.layer_visibility.insert(kind, visible); cx.notify(); } pub fn toggle_layer(&mut self, kind: LayerKind, cx: &mut Context) { let current = self.state.is_layer_visible(kind); self.set_layer_visible(kind, !current, cx); cx.emit(CanvasEvent::LayerVisibilityChanged { kind, visible: !current, }); } pub fn reset_time_offset(&mut self, cx: &mut Context) { if self.state.time_offset_minutes != 0 || self.state.view_rotation_deg != 0.0 { self.state.time_offset_minutes = 0; self.state.view_rotation_deg = 0.0; cx.emit(CanvasEvent::TimeOffsetChanged(0)); cx.notify(); } } pub fn set_view_rotation(&mut self, deg: f32, cx: &mut Context) { self.state.view_rotation_deg = deg.rem_euclid(360.0); cx.notify(); } // ----- Internos: handlers de jog-dial ----- fn on_jog_down( &mut self, position: Point, bounds: Bounds, cx: &mut Context, ) { let (cx_px, cy_px) = bounds_center(bounds); let mx: f32 = position.x.into(); let my: f32 = position.y.into(); let dx = mx - cx_px; let dy = my - cy_px; let dist = (dx * dx + dy * dy).sqrt(); let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0; let radii = Radii::from_outer(r_outer); // Aro de captura un poco más generoso que el anillo del dial. if dist < radii.sign_inner * 0.95 || dist > radii.sign_outer * 1.10 { return; } let angle = dy.atan2(dx).to_degrees(); self.state.drag_jog = Some(JogDragState { last_screen_angle_deg: angle, accumulated_delta_deg: 0.0, }); } fn on_jog_move( &mut self, position: Point, bounds: Bounds, cx: &mut Context, ) { let Some(jog) = self.state.drag_jog.as_mut() else { return; }; let (cx_px, cy_px) = bounds_center(bounds); let mx: f32 = position.x.into(); let my: f32 = position.y.into(); let dx = mx - cx_px; let dy = my - cy_px; let angle = dy.atan2(dx).to_degrees(); let mut delta = angle - jog.last_screen_angle_deg; // Normalizar a (-180, 180] para cruzar el wrap sin saltar. if delta > 180.0 { delta -= 360.0; } else if delta < -180.0 { delta += 360.0; } jog.accumulated_delta_deg += delta; jog.last_screen_angle_deg = angle; // Reflejo visual durante el drag (sin recomputar). self.state.view_rotation_deg = jog.accumulated_delta_deg; cx.notify(); } /// Hit-test sobre body glyphs + house cusps. Para bodies: distancia /// al centro del glyph dentro de threshold. Para cusps: el mouse /// debe estar cerca del ring de casas Y angularmente cerca del /// cusp (proximidad a la línea radial). fn on_hover_check( &mut self, position: Point, bounds: Bounds, cx: &mut Context, ) { let CanvasMode::Wheel { render } = &self.state.mode else { if self.state.hover.take().is_some() { cx.notify(); } return; }; let (cx_px, cy_px) = bounds_center(bounds); let mx: f32 = position.x.into(); let my: f32 = position.y.into(); let ox: f32 = bounds.origin.x.into(); let oy: f32 = bounds.origin.y.into(); let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0; let radii = Radii::from_outer(r_outer); let asc = render.ascendant_deg; let rot = self.state.view_rotation_deg; let body_threshold = 14.0_f32; let mut best: Option<(f32, HoverInfo)> = None; // 1) Body glyphs (incluye natal, overlays, midpoints). for layer in &render.layers { let ring = match layer.kind { LayerKind::Bodies => radii.body_ring(&layer.module_id), LayerKind::Midpoints => radii.midpoints, LayerKind::Outer if OUTER_RING_MODULES.contains(&layer.module_id.as_str()) => { radii.transits } _ => continue, }; for g in &layer.glyphs { let (gx, gy) = polar_to_screen(g.deg, asc, rot, ring); let dx = mx - (cx_px + gx); let dy = my - (cy_px + gy); let dist = (dx * dx + dy * dy).sqrt(); if dist > body_threshold { continue; } if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) { best = Some(( dist, HoverInfo::Body { module_id: layer.module_id.clone(), symbol: g.symbol.clone(), deg: g.deg, house: g.house, retrograde: g.retrograde, dignity_marker: g.dignity_marker.clone(), annotation: g.annotation.clone(), local_x: cx_px + gx - ox, local_y: cy_px + gy - oy, }, )); } } } // 2) House cusps — solo si el mouse está cerca del anillo de // casas (radio entre houses_inner y houses_outer + margen) y // ningún body ganó. Las cusps son líneas radiales — la // distancia angular al cusp más cercano determina el hit. if best.is_none() { let dx = mx - cx_px; let dy = my - cy_px; let mouse_r = (dx * dx + dy * dy).sqrt(); let r_in = radii.houses_inner - 6.0; let r_out = radii.houses_outer + 6.0; if mouse_r >= r_in && mouse_r <= r_out { // Calcular la longitud zodiacal que corresponde a este // ángulo de pantalla (inversa de polar_to_screen). let screen_angle_deg = dy.atan2(dx).to_degrees(); // (-180, 180] // polar_to_screen: deg = 180 - (lon - asc + rot) // → lon = asc + 180 - screen_angle_deg - rot let lon = ((asc + 180.0 - screen_angle_deg - rot) as f32).rem_euclid(360.0); // Buscar cusp más cercano (con wraparound). for layer in &render.layers { if matches!(layer.kind, LayerKind::Houses) { if let Geometry::Ring { cusps_deg } = &layer.geometry { for (i, c) in cusps_deg.iter().enumerate() { let mut diff = (lon - c).abs(); if diff > 180.0 { diff = 360.0 - diff; } if diff < 2.5 { // Mouse cerca de ESTE cusp. let (gx, gy) = polar_to_screen( *c, asc, rot, (radii.houses_inner + radii.houses_outer) / 2.0, ); best = Some(( diff, HoverInfo::HouseCusp { house_number: (i as u8) + 1, deg: *c, local_x: cx_px + gx - ox, local_y: cy_px + gy - oy, }, )); break; } } } } } } } let new_hover = best.map(|(_, h)| h); let changed = match (&self.state.hover, &new_hover) { (Some(a), Some(b)) => a.key() != b.key(), (None, None) => false, _ => true, }; if changed { self.state.hover = new_hover; cx.notify(); } } fn on_jog_up(&mut self, cx: &mut Context) { let Some(jog) = self.state.drag_jog.take() else { return; }; // 1° de arco ≈ 4 minutos de tiempo sideral (15°/hora). // CW visual (delta negativa en nuestra convención) → tiempo // hacia adelante. let delta_minutes = (-jog.accumulated_delta_deg * 4.0) as i64; if delta_minutes != 0 { self.state.time_offset_minutes = self.state.time_offset_minutes.saturating_add(delta_minutes); // Snap visual: el shell recomputa con el nuevo offset y el // render trae el ascendant rotado. self.state.view_rotation_deg = 0.0; cx.emit(CanvasEvent::TimeOffsetChanged(self.state.time_offset_minutes)); cx.notify(); } else { self.state.view_rotation_deg = 0.0; cx.notify(); } } fn on_key_down(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context) { let key = event.keystroke.key.as_str(); let kind = match key { "d" | "D" => LayerKind::SignDial, "h" | "H" => LayerKind::Houses, "x" | "X" => LayerKind::Aspects, "p" | "P" => LayerKind::Bodies, "t" | "T" => LayerKind::Outer, "r" | "R" => { self.reset_time_offset(cx); return; } _ => return, }; self.toggle_layer(kind, cx); } } // ===================================================================== // Geometría de pantalla // ===================================================================== const WHEEL_SIZE: f32 = 580.0; const WHEEL_MARGIN: f32 = 28.0; fn bounds_center(bounds: Bounds) -> (f32, f32) { let ox: f32 = bounds.origin.x.into(); let oy: f32 = bounds.origin.y.into(); let bw: f32 = bounds.size.width.into(); let bh: f32 = bounds.size.height.into(); (ox + bw / 2.0, oy + bh / 2.0) } // ===================================================================== // Render // ===================================================================== impl Render for AstrologyCanvas { fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = Theme::global(cx).clone(); let palette = AstroPalette::for_theme(&theme); let entity = cx.entity(); let focus = self.focus_handle.clone(); let body = match &self.state.mode { CanvasMode::Empty => render_empty(&theme), CanvasMode::Wheel { render } => render_wheel( &theme, &palette, render, self.state.view_rotation_deg, self.state.time_offset_minutes, &self.state.layer_visibility, self.state.hover.as_ref(), entity, ), CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items), }; div() .id("astrology-canvas-root") .track_focus(&focus) .key_context("AstrologyCanvas") .on_key_down(cx.listener(Self::on_key_down)) .on_mouse_down( MouseButton::Left, cx.listener(|this, _, w, _cx| { w.focus(&this.focus_handle); }), ) .size_full() .bg(theme.bg_panel.clone()) .flex() .flex_col() .items_center() .justify_center() .child(body) } } // ===================================================================== // Modos: empty / thumbnails / wheel // ===================================================================== fn render_empty(theme: &Theme) -> gpui::Div { div() .flex() .flex_col() .items_center() .justify_center() .gap(px(12.0)) .child( div() .text_size(px(13.0)) .text_color(theme.fg_muted) .child("Tahuantinsuyu"), ) .child( div() .text_size(px(11.0)) .text_color(theme.fg_disabled) .child("Seleccioná una carta en el árbol para empezar."), ) } fn render_thumbnails(theme: &Theme, items: &[ThumbnailItem]) -> gpui::Div { if items.is_empty() { return div() .text_size(px(12.0)) .text_color(theme.fg_muted) .child("Sin cartas en este grupo todavía."); } let mut row = div().flex().flex_row().flex_wrap().gap(px(12.0)); for it in items { row = row.child( div() .w(px(140.0)) .h(px(160.0)) .rounded(px(6.0)) .border_1() .border_color(theme.border) .bg(theme.bg_panel_alt.clone()) .flex() .flex_col() .items_center() .justify_end() .pb(px(8.0)) .child( div() .text_size(px(11.0)) .text_color(theme.fg_text) .child(it.label.clone()), ), ); } row } // ===================================================================== // Wheel // ===================================================================== #[allow(clippy::too_many_arguments)] fn render_wheel( theme: &Theme, palette: &AstroPalette, render: &RenderModel, view_rotation_deg: f32, time_offset_minutes: i64, layer_visibility: &HashMap, hover: Option<&HoverInfo>, entity: gpui::Entity, ) -> gpui::Div { let asc = render.ascendant_deg; let rot_offset = view_rotation_deg; let cx_center = WHEEL_SIZE / 2.0; let cy_center = WHEEL_SIZE / 2.0; let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0; let radii = Radii::from_outer(r_outer); let visible = layer_visibility.clone(); // --- Canvas element con todo el trazo + jog-dial drag --- let palette_paint = palette.clone(); let theme_paint = theme.clone(); let layers_paint: Vec = render.layers.clone(); let asc_for_paint = asc; let mc_for_paint = render.midheaven_deg; let visibility_for_paint = visible.clone(); let entity_for_canvas = entity.clone(); let canvas_element = canvas( move |_b: Bounds, _w, _cx| (), move |bounds: Bounds, _, window, _| { // Painting de la rueda. paint_wheel( bounds, window, &theme_paint, &palette_paint, &layers_paint, asc_for_paint, mc_for_paint, rot_offset, radii, &visibility_for_paint, ); // Handlers de mouse para el jog-dial — se registran cada // frame contra el window; GPUI los reemplaza al re-renderear. let entity_d = entity_for_canvas.clone(); window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| { if ev.button != MouseButton::Left { return; } if !bounds.contains(&ev.position) { return; } entity_d.update(cx, |this, cx| this.on_jog_down(ev.position, bounds, cx)); }); let entity_m = entity_for_canvas.clone(); window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| { if ev.dragging() { entity_m.update(cx, |this, cx| this.on_jog_move(ev.position, bounds, cx)); } else if bounds.contains(&ev.position) { // Mouse hover sin drag: hit-test sobre los body // glyphs para el tooltip. entity_m.update(cx, |this, cx| this.on_hover_check(ev.position, bounds, cx)); } else { entity_m.update(cx, |this, cx| { if this.state.hover.take().is_some() { cx.notify(); } }); } }); let entity_u = entity_for_canvas.clone(); window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| { entity_u.update(cx, |this, cx| this.on_jog_up(cx)); }); }, ) .absolute() .w(px(WHEEL_SIZE)) .h(px(WHEEL_SIZE)); // Gradient sutil diagonal en el fondo del wheel — toque "místico // velado": alpha muy baja, así no compite con la geometría pintada // encima pero llena las zonas vacías (esquinas del cuadrado, gaps // entre anillos) con un shimmer mineral. let wheel_bg = linear_gradient( 155.0, linear_color_stop(with_alpha(palette.dial_ring, 0.06), 0.0), linear_color_stop(with_alpha(palette.angle_highlight, 0.03), 1.0), ); let mut wheel = div() .relative() .w(px(WHEEL_SIZE)) .h(px(WHEEL_SIZE)) .bg(wheel_bg) .rounded(px(12.0)) .child(canvas_element); // Sign glyphs. if visible.get(&LayerKind::SignDial).copied().unwrap_or(true) { let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0; for layer in &render.layers { if matches!(layer.kind, LayerKind::SignDial) { for g in &layer.glyphs { let (x, y) = polar_to_screen(g.deg, asc, rot_offset, sign_ring_mid); let color = element_color_for_sign(palette, &g.symbol); wheel = wheel.child(centered_glyph( cx_center + x, cy_center + y, 20.0, 18.0, sign_unicode(&g.symbol).into(), color, )); } } } } // House numbers. if visible.get(&LayerKind::Houses).copied().unwrap_or(true) { let house_label_r = (radii.houses_outer + radii.houses_inner) / 2.0; for layer in &render.layers { if matches!(layer.kind, LayerKind::Houses) { for g in &layer.glyphs { let (x, y) = polar_to_screen(g.deg, asc, rot_offset, house_label_r); if let Some(h) = g.house { wheel = wheel.child(centered_glyph( cx_center + x, cy_center + y, 16.0, 10.0, format!("{}", h).into(), palette.house_cusp, )); } } } } } // Planet glyphs: natal (en `bodies`) + overlays en sus rings // (progression, solar_arc) con alpha + tamaño más chico para // diferenciarse visualmente del natal. if visible.get(&LayerKind::Bodies).copied().unwrap_or(true) { for layer in &render.layers { if matches!(layer.kind, LayerKind::Bodies) { let is_natal = layer.module_id == "natal"; let ring = radii.body_ring(&layer.module_id); let alpha = if is_natal { 1.0 } else { 0.85 }; let font_size = if is_natal { 18.0 } else { 14.0 }; let box_size = if is_natal { 24.0 } else { 20.0 }; for g in &layer.glyphs { let (x, y) = polar_to_screen(g.deg, asc, rot_offset, ring); let color = with_alpha(planet_color(palette, &g.symbol), alpha); let mut glyph_text = planet_unicode(&g.symbol).to_string(); if g.retrograde { glyph_text.push('ᴿ'); } if let Some(marker) = &g.dignity_marker { glyph_text.push_str(marker); } wheel = wheel.child(centered_glyph( cx_center + x, cy_center + y, box_size, font_size, glyph_text.into(), color, )); } } } } // Planet glyphs en el outer ring — transit o synastry, los dos // comparten ese slot (mutuamente excluyentes a nivel de Shell). if visible.get(&LayerKind::Outer).copied().unwrap_or(true) { for layer in &render.layers { if matches!(layer.kind, LayerKind::Outer) && (OUTER_RING_MODULES.contains(&layer.module_id.as_str())) { for g in &layer.glyphs { let (x, y) = polar_to_screen(g.deg, asc, rot_offset, radii.transits); let color = with_alpha(planet_color(palette, &g.symbol), 0.9); let glyph_text = if g.retrograde { format!("{}ᴿ", planet_unicode(&g.symbol)) } else { planet_unicode(&g.symbol).into() }; wheel = wheel.child(centered_glyph( cx_center + x, cy_center + y, 20.0, 14.0, glyph_text.into(), color, )); } } } } // Tooltip absoluto sobre el elemento hovered (cuerpo o cusp). if let Some(hov) = hover { let text = match hov { HoverInfo::Body { module_id, symbol, deg, house, retrograde, dignity_marker, annotation, .. } => { let sign_idx = ((deg / 30.0).floor() as usize) % 12; let sign_name = SIGN_NAMES_ES[sign_idx]; let deg_in_sign = deg - (sign_idx as f32) * 30.0; let display_symbol = if module_id == "midpoints" { // El symbol del midpoint es "a/b" — para el header // del tooltip usamos los unicodes individuales. if let Some((a, b)) = symbol.split_once('/') { format!("{}/{}", planet_unicode(a), planet_unicode(b)) } else { symbol.clone() } } else { planet_unicode(symbol).to_string() }; let mut t = format!("{} {} · {:.1}°", display_symbol, sign_name, deg_in_sign); if let Some(h) = house { t.push_str(&format!(" · Casa {}", h)); } if *retrograde { t.push_str(" · ℞"); } if let Some(m) = dignity_marker { t.push_str(&format!(" · {}", m)); } if module_id == "midpoints" { if let Some(a) = annotation { t.push_str(&format!(" · {}", a)); } } else if module_id != "natal" { t.push_str(&format!(" · {}", module_id)); } t } HoverInfo::HouseCusp { house_number, deg, .. } => { let sign_idx = ((deg / 30.0).floor() as usize) % 12; let sign_name = SIGN_NAMES_ES[sign_idx]; let deg_in_sign = deg - (sign_idx as f32) * 30.0; format!( "Cusp Casa {} · {} {:.1}°", house_number, sign_name, deg_in_sign ) } }; let (lx, ly) = hov.local(); let tip_x = (lx + 14.0).min(WHEEL_SIZE - 220.0).max(8.0); let tip_y = (ly - 28.0).max(8.0); wheel = wheel.child( div() .absolute() .left(px(tip_x)) .top(px(tip_y)) .px(px(8.0)) .py(px(4.0)) .rounded(px(6.0)) .bg(theme.bg_panel_alt.clone()) .border_1() .border_color(palette.angle_highlight) .text_size(px(11.0)) .text_color(theme.fg_text) .child(SharedString::from(text)), ); } // Labels ASC/MC/DESC/IC en el perímetro. Texto pequeño en el // margen exterior (radius * 1.05) para que no se monte con los // glifos de los signos. Color angle_highlight para que el ojo los // reconozca como los cuatro ángulos cardinales. let angle_labels = [ (asc, "ASC"), (render.midheaven_deg, "MC"), (render.descendant_deg, "DESC"), (render.imum_coeli_deg, "IC"), ]; let label_r = r_outer * 1.06; for (deg, label) in angle_labels { let (x, y) = polar_to_screen(deg, asc, rot_offset, label_r); wheel = wheel.child(centered_glyph( cx_center + x, cy_center + y, 32.0, 10.0, label.into(), palette.angle_highlight, )); } // --- Header + footer + indicador de tiempo --- let header = div() .flex() .flex_col() .items_center() .gap(px(2.0)) .child( div() .text_size(px(16.0)) .text_color(theme.fg_text) .child(SharedString::from(render.title.clone())), ); let header = if let Some(sub) = &render.subtitle { header.child( div() .text_size(px(11.0)) .text_color(theme.fg_muted) .child(SharedString::from(sub.clone())), ) } else { header }; // Botón export SVG — pequeño, alineado a la derecha del title. let export_btn = div() .id("tts-canvas-export-svg") .px(px(10.0)) .py(px(3.0)) .rounded(px(4.0)) .bg(theme.bg_button()) .hover(|s| s.bg(theme.bg_button_hover())) .border_1() .border_color(theme.border) .text_size(px(10.0)) .text_color(theme.fg_text) .child("⬇ SVG") .on_click({ let entity_e = entity.clone(); move |_: &gpui::ClickEvent, _w, cx: &mut gpui::App| { entity_e.update(cx, |_this, cx| { cx.emit(CanvasEvent::ExportSvgRequested); }); } }); let header = div() .flex() .flex_row() .items_center() .gap(px(12.0)) .child(header) .child(export_btn); let offset_label = format_offset(time_offset_minutes); let offset_color = if time_offset_minutes == 0 { theme.fg_disabled } else { palette.angle_highlight }; let info_row = div() .flex() .flex_row() .gap(px(10.0)) .child( div() .text_size(px(10.0)) .text_color(theme.fg_disabled) .child(SharedString::from(format!( "Asc {:.1}° · MC {:.1}° · {} ms", render.ascendant_deg, render.midheaven_deg, render.compute_ms, ))), ) .child( div() .text_size(px(10.0)) .text_color(offset_color) .child(SharedString::from(offset_label)), ) .child( div() .text_size(px(10.0)) .text_color(theme.fg_disabled) .child("[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [R]eset"), ); // Badges de overlays activos. Cada uno se pinta como pill con // background sutil y border tenue. Solo aparecen cuando hay // overlays — la carta natal pura ve solo el info_row. let badges_row = if render.overlays.is_empty() { None } else { let mut row = div().flex().flex_row().flex_wrap().gap(px(6.0)); // Badge "natal" base, siempre presente cuando hay overlays — // ayuda al usuario a leer la pila de izquierda a derecha. row = row.child(badge(theme, palette, "natal", "Natal", true)); for ov in &render.overlays { row = row.child(badge(theme, palette, &ov.module_id, &ov.label, false)); } Some(row) }; let mut footer = div().flex().flex_col().items_center().gap(px(4.0)).child(info_row); if let Some(b) = badges_row { footer = footer.child(b); } // Lista textual de aspectos (top 12 por orb). Compacta, en grid // de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos // computados. if !render.aspect_summary.is_empty() { let mut grid = div() .flex() .flex_row() .flex_wrap() .gap(px(10.0)) .max_w(px(WHEEL_SIZE + 80.0)) .justify_center(); for ap in render.aspect_summary.iter().take(12) { let kind_sym = aspect_unicode(&ap.kind); let line = format!( "{} {} {} · {:.1}°{}", planet_unicode(&ap.from_body), kind_sym, planet_unicode(&ap.to_body), ap.orb_deg, match ap.applying { Some(true) => " A", Some(false) => " S", None => "", } ); let prefix = if ap.module_id == "natal" { String::new() } else { format!("[{}] ", ap.module_id) }; grid = grid.child( div() .px(px(6.0)) .py(px(2.0)) .text_size(px(11.0)) .text_color(aspect_color(palette, &ap.kind)) .child(SharedString::from(format!("{}{}", prefix, line))), ); } footer = footer.child(grid); } div() .flex() .flex_col() .items_center() .gap(px(8.0)) .child(header) .child(wheel) .child(footer) } /// Pequeña pill con la etiqueta de un overlay activo. El borde toma /// color según el "tipo" del módulo para ayudar a mapear a su anillo /// en el wheel: natal = neutro, outer ring share (transit/synastry/ /// planetary_return) = palette.angle_highlight, inner overlays /// (progression/solar_arc) = palette.house_cusp. fn badge(theme: &Theme, palette: &AstroPalette, module_id: &str, label: &str, is_natal: bool) -> gpui::Div { let border = if is_natal { theme.border } else { match module_id { "transit" | "synastry" | "planetary_return" => palette.angle_highlight, "progression" | "solar_arc" => palette.house_cusp, _ => theme.border, } }; div() .px(px(8.0)) .py(px(2.0)) .rounded(px(10.0)) .bg(theme.bg_panel_alt.clone()) .border_1() .border_color(border) .text_size(px(10.0)) .text_color(theme.fg_text) .child(SharedString::from(label.to_string())) } fn format_offset(minutes: i64) -> String { if minutes == 0 { return "⏱ ahora".to_string(); } let sign = if minutes > 0 { '+' } else { '-' }; let m = minutes.unsigned_abs(); let days = m / (60 * 24); let hours = (m / 60) % 24; let mins = m % 60; if days > 0 { format!("⏱ {}{}d {:02}h {:02}m", sign, days, hours, mins) } else if hours > 0 { format!("⏱ {}{:02}h {:02}m", sign, hours, mins) } else { format!("⏱ {}{:02}m", sign, mins) } } // ===================================================================== // Painting // ===================================================================== #[derive(Clone, Copy)] struct Radii { sign_outer: f32, sign_inner: f32, /// Anillo de glifos de tránsito (cuando el overlay está activo). transits: f32, houses_outer: f32, houses_inner: f32, /// Anillo de midpoints — entre bodies natales y houses_inner. midpoints: f32, bodies: f32, /// Anillo interno con cuerpos progresados (overlay opcional). progression: f32, /// Anillo más interno con cuerpos dirigidos por Solar Arc. solar_arc: f32, /// Anillo de carta compuesta (midpoint Davison) con un partner. composite: f32, aspects: f32, } impl Radii { fn from_outer(r: f32) -> Self { Self { sign_outer: r, sign_inner: r * 0.88, transits: r * 0.82, houses_outer: r * 0.78, houses_inner: r * 0.66, midpoints: r * 0.62, bodies: r * 0.58, progression: r * 0.48, solar_arc: r * 0.40, composite: r * 0.32, aspects: r * 0.24, } } /// Radio del ring de cuerpos según el `module_id` del Layer. fn body_ring(&self, module_id: &str) -> f32 { match module_id { "progression" => self.progression, "solar_arc" => self.solar_arc, "composite" => self.composite, "midpoints" => self.midpoints, _ => self.bodies, } } /// Resuelve qué radios corresponden a una capa de aspectos según el /// `module_id`: natal-natal en `aspects`, cross con cada overlay /// desde `bodies` (extremo natal) al ring del módulo. Los módulos /// del outer ring (OUTER_RING_MODULES) comparten el slot de /// tránsito (son mutuamente excluyentes a nivel de Shell). fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) { if OUTER_RING_MODULES.contains(&module_id) { return (self.bodies, self.transits); } match module_id { "progression" => (self.bodies, self.progression), "solar_arc" => (self.bodies, self.solar_arc), "composite" => (self.bodies, self.composite), _ => (self.aspects, self.aspects), } } } #[allow(clippy::too_many_arguments)] fn paint_wheel( bounds: Bounds, window: &mut Window, theme: &Theme, palette: &AstroPalette, layers: &[Layer], ascendant_deg: f32, midheaven_deg: f32, rot_offset_deg: f32, radii: Radii, visibility: &HashMap, ) { let (cx, cy) = bounds_center(bounds); let _ = theme; let show = |k: LayerKind| visibility.get(&k).copied().unwrap_or(true); // 1. Sectores zodiacales (parte del SignDial layer). if show(LayerKind::SignDial) { paint_sign_sectors(window, cx, cy, &radii, palette, ascendant_deg, rot_offset_deg); // Anillos del dial. stroke_circle(window, cx, cy, radii.sign_outer, 1.5, palette.dial_ring); stroke_circle(window, cx, cy, radii.sign_inner, 1.0, palette.dial_ring); // Cusps zodiacales cada 30°. for i in 0..12 { let lon = (i as f32) * 30.0; let color = palette.dial_ring; paint_radial_line( window, cx, cy, lon, ascendant_deg, rot_offset_deg, radii.sign_inner, radii.sign_outer, color, 1.0, ); } } // 2. Casas — cusps radiales + énfasis Asc/IC/Desc/MC. if show(LayerKind::Houses) { stroke_circle( window, cx, cy, radii.houses_inner, 0.8, with_alpha(palette.house_cusp, 0.6), ); for layer in layers { if matches!(layer.kind, LayerKind::Houses) { if let Geometry::Ring { cusps_deg } = &layer.geometry { for (i, c) in cusps_deg.iter().enumerate() { let is_angle = i == 0 || i == 3 || i == 6 || i == 9; let color = if is_angle { palette.angle_highlight } else { with_alpha(palette.house_cusp, 0.7) }; let width = if is_angle { 2.0 } else { 0.8 }; paint_radial_line( window, cx, cy, *c, ascendant_deg, rot_offset_deg, radii.houses_inner, radii.houses_outer, color, width, ); } } } } // Asc + MC extendidos hasta el centro con opacidad sutil. paint_radial_line( window, cx, cy, ascendant_deg, ascendant_deg, rot_offset_deg, 0.0, radii.houses_outer, with_alpha(palette.angle_highlight, 0.35), 1.0, ); paint_radial_line( window, cx, cy, midheaven_deg, ascendant_deg, rot_offset_deg, 0.0, radii.houses_outer, with_alpha(palette.angle_highlight, 0.35), 1.0, ); } // 3. Aspectos. Cada module_id usa su par de radios — natal-natal // ambos en `aspects`, cross con transit en `bodies → transits`, // cross con progression en `bodies → progression`. if show(LayerKind::Aspects) { for layer in layers { if matches!(layer.kind, LayerKind::Aspects) { if let Geometry::Lines(segs) = &layer.geometry { let (r_from, r_to) = radii.aspect_endpoints(&layer.module_id); let is_cross = r_from != r_to; for seg in segs { let color = aspect_color(palette, &seg.kind); let color = with_alpha(color, color.a * seg.opacity); if is_cross { paint_cross_aspect_line( window, cx, cy, seg.from_deg, seg.to_deg, ascendant_deg, rot_offset_deg, r_from, r_to, color, ); } else { paint_aspect_line( window, cx, cy, seg.from_deg, seg.to_deg, ascendant_deg, rot_offset_deg, r_from, color, ); } } } } } } // 4. Dots de cuerpos: natal en `bodies`, overlays en sus rings // específicos (progression, solar_arc). Las luminarias natales // (Sol/Luna) llevan glow halo — invita la mística sin saturar. if show(LayerKind::Bodies) { let dot_r = (radii.sign_outer * 0.018).max(2.0); for layer in layers { if matches!(layer.kind, LayerKind::Bodies) { let ring = radii.body_ring(&layer.module_id); let is_natal = layer.module_id == "natal"; let alpha = if is_natal { 1.0 } else { 0.85 }; for g in &layer.glyphs { let color = with_alpha(planet_color(palette, &g.symbol), alpha); let (x, y) = polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, ring); if is_natal && (g.symbol == "sun" || g.symbol == "moon") { paint_glow(window, cx + x, cy + y, dot_r, color); } fill_circle(window, cx + x, cy + y, dot_r, color); } } } } // Anillos guía para los overlays internos (progression, solar_arc). let guide_inset = radii.sign_outer * 0.03; for (module_id, ring) in [ ("progression", radii.progression), ("solar_arc", radii.solar_arc), ] { let active = layers .iter() .any(|l| matches!(l.kind, LayerKind::Bodies) && l.module_id == module_id); if active { stroke_circle( window, cx, cy, ring + guide_inset, 0.5, with_alpha(palette.house_cusp, 0.35), ); stroke_circle( window, cx, cy, ring - guide_inset, 0.5, with_alpha(palette.house_cusp, 0.35), ); } } // 5. Outer ring (transit o synastry overlay): anillo guía + dots // de la capa activa. Son mutuamente excluyentes a nivel de Shell; // si alguno de los dos está prendido, pintamos el slot. let outer_active = layers.iter().any(|l| { matches!(l.kind, LayerKind::Outer) && OUTER_RING_MODULES.contains(&l.module_id.as_str()) }); if outer_active && show(LayerKind::Outer) { stroke_circle( window, cx, cy, radii.transits + radii.sign_outer * 0.035, 0.6, with_alpha(palette.dial_ring, 0.4), ); stroke_circle( window, cx, cy, radii.transits - radii.sign_outer * 0.035, 0.6, with_alpha(palette.dial_ring, 0.4), ); let dot_r = (radii.sign_outer * 0.017).max(2.0); for layer in layers { if matches!(layer.kind, LayerKind::Outer) && (OUTER_RING_MODULES.contains(&layer.module_id.as_str())) { for g in &layer.glyphs { let color = with_alpha(planet_color(palette, &g.symbol), 0.85); let (x, y) = polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, radii.transits); fill_circle(window, cx + x, cy + y, dot_r, color); } } } } } fn paint_sign_sectors( window: &mut Window, cx: f32, cy: f32, radii: &Radii, palette: &AstroPalette, ascendant_deg: f32, rot_offset_deg: f32, ) { const SUBDIVISIONS: usize = 18; for i in 0..12 { let lon_start = (i as f32) * 30.0; let lon_end = lon_start + 30.0; let element = sign_element_by_index(i); let color = with_alpha(palette.element(element), 0.10); let mut builder = PathBuilder::fill(); let (x0, y0) = polar_to_screen(lon_start, ascendant_deg, rot_offset_deg, radii.sign_inner); builder.move_to(point(px(cx + x0), px(cy + y0))); for k in 1..=SUBDIVISIONS { let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32); let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_inner); builder.line_to(point(px(cx + x), px(cy + y))); } let (xe, ye) = polar_to_screen(lon_end, ascendant_deg, rot_offset_deg, radii.sign_outer); builder.line_to(point(px(cx + xe), px(cy + ye))); for k in (0..SUBDIVISIONS).rev() { let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32); let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_outer); builder.line_to(point(px(cx + x), px(cy + y))); } builder.close(); if let Ok(path) = builder.build() { window.paint_path(path, color); } } } fn stroke_circle(window: &mut Window, cx: f32, cy: f32, r: f32, width: f32, color: Hsla) { const SEGMENTS: usize = 96; let mut builder = PathBuilder::stroke(px(width)); for i in 0..=SEGMENTS { let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI); let x = cx + r * t.cos(); let y = cy + r * t.sin(); if i == 0 { builder.move_to(point(px(x), px(y))); } else { builder.line_to(point(px(x), px(y))); } } if let Ok(path) = builder.build() { window.paint_path(path, color); } } /// Pinta 3 halos concéntricos con alpha decreciente alrededor de un /// punto — usado para Sol/Luna natales. El radio crece, la opacidad /// cae: el ojo lo lee como "esto irradia". Sin glow real (GPUI 0.2 no /// tiene radial gradient), pero el shading concéntrico convence. fn paint_glow(window: &mut Window, cx: f32, cy: f32, base_r: f32, color: Hsla) { const HALOS: [(f32, f32); 3] = [(5.0, 0.05), (3.0, 0.12), (1.8, 0.22)]; for (mult, alpha) in HALOS { let r = base_r * mult; let halo = hsla(color.h, color.s, color.l, alpha); fill_circle(window, cx, cy, r, halo); } } fn fill_circle(window: &mut Window, cx: f32, cy: f32, r: f32, color: Hsla) { const SEGMENTS: usize = 32; let mut builder = PathBuilder::fill(); builder.move_to(point(px(cx + r), px(cy))); for i in 1..=SEGMENTS { let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI); let x = cx + r * t.cos(); let y = cy + r * t.sin(); builder.line_to(point(px(x), px(y))); } builder.close(); if let Ok(path) = builder.build() { window.paint_path(path, color); } } #[allow(clippy::too_many_arguments)] fn paint_radial_line( window: &mut Window, cx: f32, cy: f32, longitude_deg: f32, ascendant_deg: f32, rot_offset_deg: f32, r_inner: f32, r_outer: f32, color: Hsla, width: f32, ) { let (xi, yi) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_inner); let (xo, yo) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_outer); let mut builder = PathBuilder::stroke(px(width)); builder.move_to(point(px(cx + xi), px(cy + yi))); builder.line_to(point(px(cx + xo), px(cy + yo))); if let Ok(path) = builder.build() { window.paint_path(path, color); } } #[allow(clippy::too_many_arguments)] fn paint_aspect_line( window: &mut Window, cx: f32, cy: f32, a_deg: f32, b_deg: f32, ascendant_deg: f32, rot_offset_deg: f32, r: f32, color: Hsla, ) { let (xa, ya) = polar_to_screen(a_deg, ascendant_deg, rot_offset_deg, r); let (xb, yb) = polar_to_screen(b_deg, ascendant_deg, rot_offset_deg, r); let mut builder = PathBuilder::stroke(px(1.0)); builder.move_to(point(px(cx + xa), px(cy + ya))); builder.line_to(point(px(cx + xb), px(cy + yb))); if let Ok(path) = builder.build() { window.paint_path(path, color); } } /// Línea de aspecto natal ↔ tránsito: extremos en radios distintos. /// El `from_deg` cae sobre el ring de cuerpos natales (`r_from`); el /// `to_deg` sobre el ring de tránsito (`r_to`). Trazo más fino que el /// natal-natal para no competir visualmente. #[allow(clippy::too_many_arguments)] fn paint_cross_aspect_line( window: &mut Window, cx: f32, cy: f32, natal_deg: f32, transit_deg: f32, ascendant_deg: f32, rot_offset_deg: f32, r_from: f32, r_to: f32, color: Hsla, ) { let (xa, ya) = polar_to_screen(natal_deg, ascendant_deg, rot_offset_deg, r_from); let (xb, yb) = polar_to_screen(transit_deg, ascendant_deg, rot_offset_deg, r_to); let mut builder = PathBuilder::stroke(px(0.7)); builder.move_to(point(px(cx + xa), px(cy + ya))); builder.line_to(point(px(cx + xb), px(cy + yb))); if let Ok(path) = builder.build() { window.paint_path(path, color); } } // ===================================================================== // Helpers // ===================================================================== fn polar_to_screen( longitude_deg: f32, ascendant_deg: f32, rot_offset_deg: f32, radius: f32, ) -> (f32, f32) { let deg = 180.0 - (longitude_deg - ascendant_deg + rot_offset_deg); let rad = deg * PI / 180.0; (radius * rad.cos(), radius * rad.sin()) } fn centered_glyph( x: f32, y: f32, box_size: f32, font_size: f32, text: SharedString, color: Hsla, ) -> gpui::Div { div() .absolute() .left(px(x - box_size / 2.0)) .top(px(y - box_size / 2.0)) .w(px(box_size)) .h(px(box_size)) .flex() .items_center() .justify_center() .text_size(px(font_size)) .text_color(color) .child(text) } fn with_alpha(c: Hsla, a: f32) -> Hsla { hsla(c.h, c.s, c.l, a.clamp(0.0, 1.0)) } fn sign_unicode(name: &str) -> &'static str { match name { "aries" => "♈", "taurus" => "♉", "gemini" => "♊", "cancer" => "♋", "leo" => "♌", "virgo" => "♍", "libra" => "♎", "scorpio" => "♏", "sagittarius" => "♐", "capricorn" => "♑", "aquarius" => "♒", "pisces" => "♓", _ => "?", } } const SIGN_NAMES_ES: [&str; 12] = [ "Aries", "Tauro", "Géminis", "Cáncer", "Leo", "Virgo", "Libra", "Escorpio", "Sagitario", "Capricornio", "Acuario", "Piscis", ]; fn aspect_unicode(kind: &str) -> &'static str { match kind { "conjunction" => "☌", "opposition" => "☍", "trine" => "△", "square" => "□", "sextile" => "⚹", "quincunx" => "⚻", "semi_sextile" => "⚺", "semi_square" => "∠", "sesquiquadrate" => "⚼", "quintile" => "Q", "biquintile" => "bQ", _ => "·", } } fn planet_unicode(name: &str) -> &'static str { match name { "sun" => "☉", "moon" => "☽", "mercury" => "☿", "venus" => "♀", "mars" => "♂", "jupiter" => "♃", "saturn" => "♄", "uranus" => "♅", "neptune" => "♆", "pluto" => "♇", "north_node" => "☊", "south_node" => "☋", "chiron" => "⚷", "lilith" => "⚸", "ceres" => "⚳", "pallas" => "⚴", "juno" => "⚵", "vesta" => "⚶", _ => "•", } } fn planet_color(p: &AstroPalette, name: &str) -> Hsla { let planet = match name { "sun" => Planet::Sun, "moon" => Planet::Moon, "mercury" => Planet::Mercury, "venus" => Planet::Venus, "mars" => Planet::Mars, "jupiter" => Planet::Jupiter, "saturn" => Planet::Saturn, "uranus" => Planet::Uranus, "neptune" => Planet::Neptune, "pluto" => Planet::Pluto, "chiron" => Planet::Chiron, "north_node" => Planet::NorthNode, "south_node" => Planet::SouthNode, "lilith" => Planet::Lilith, _ => return p.fg_text_fallback(), }; p.planet(planet) } fn sign_element_by_index(i: usize) -> Element { match i % 4 { 0 => Element::Fire, 1 => Element::Earth, 2 => Element::Air, _ => Element::Water, } } fn element_color_for_sign(p: &AstroPalette, name: &str) -> Hsla { let elem = match name { "aries" | "leo" | "sagittarius" => Element::Fire, "taurus" | "virgo" | "capricorn" => Element::Earth, "gemini" | "libra" | "aquarius" => Element::Air, "cancer" | "scorpio" | "pisces" => Element::Water, _ => return p.fg_text_fallback(), }; p.element(elem) } fn aspect_color(p: &AstroPalette, kind: &str) -> Hsla { let k = match kind { "conjunction" => TAspectKind::Conjunction, "opposition" => TAspectKind::Opposition, "trine" => TAspectKind::Trine, "square" => TAspectKind::Square, "sextile" => TAspectKind::Sextile, "quincunx" => TAspectKind::Quincunx, "semi_sextile" => TAspectKind::Semisextile, "semi_square" => TAspectKind::Semisquare, "sesquiquadrate" => TAspectKind::Sesquisquare, "quintile" => TAspectKind::Quintile, "biquintile" => TAspectKind::Biquintile, _ => return p.minor_aspect, }; p.aspect(k) } trait AstroPaletteExt { fn fg_text_fallback(&self) -> Hsla; } impl AstroPaletteExt for AstroPalette { fn fg_text_fallback(&self) -> Hsla { if self.is_dark { hsla(0.0, 0.0, 0.85, 1.0) } else { hsla(0.0, 0.0, 0.25, 1.0) } } }