From 1078e433f24ac83e2540343e82d2bd0fe49e304a Mon Sep 17 00:00:00 2001 From: sergio Date: Mon, 18 May 2026 15:45:48 +0000 Subject: [PATCH] feat(tahuantinsuyu): rueda 3D, hover-highlight, universo, themes papel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Segunda tanda de UX a partir de feedback de uso real: - Zoom/pan reasignados: wheel = zoom puro (sin modifier). LMB drag fuera del anillo de signos = pan; sobre el anillo = jog-dial (rectificación). MMB sigue como pan secundario. Tecla `0` resetea zoom + pan. - Planetas legibles: el "dot rellenado" se reduce a 3 px (solo marca el grado exacto). Encima va `planet_glyph` con disco-halo del bg_panel y border del color del planeta — el glyph unicode astronómico (☉☽☿♀♂♃♄♅♆♇) ahora se lee contra cualquier fondo. - Aspectos hover-highlight: al hovear un planeta, sus líneas se mantienen al 100 % y el resto cae a 18 %. Resuelve el "¿quién contra quién?" sin desordenar la rueda. - Ascensionales: cruz completa ASC-DESC + MC-IC (4 radios) con α=0.55. Labels ASC/MC/DESC/IC como pills con bg-halo y border `angle_highlight`, font 11 — antes eran texto chico que se fundía con el dial. - Universo: el wheel pierde su bg de cuadrado (que cortaba contra el panel). El root del canvas pinta un starfield sutil ~130 puntos deterministas (xorshift32 con seed fija, sin parpadeo entre frames). Solo activo en themes dark — sobre fondos claros generaría ruido. - Estilo 3D anillos: `stroke_circle_3d` (highlight +luma + base + shadow -luma) reemplaza al stroke plano en sign_outer, sign_inner y el outer ring. Más `paint_dial_bevel` con 10 strokes finos en bell curve entre sign_inner y sign_outer — simula gradient radial que gpui canvas no soporta nativo. - Theme `Print Color`: papel crema, paleta astro con luminancia 0.26-0.34 y saturación alta, sin glow ni gradients. - Theme `Print B&W`: monocromático sobre blanco puro. Aspectos diferenciados por dash pattern en lugar de color: conjunction/opposition sólidos, square dash medio, trine dash largo, sextile dotted, minors dotted finísimo. `paint_segment` con `dash: Option<(on,off)>` para implementar dashes (gpui canvas no tiene stroke dash nativo). Todos los tests siguen verdes (6 shell + 5 yahweh-theme + 2 tahuantinsuyu-theme). Co-Authored-By: Claude Opus 4.7 --- .../tahuantinsuyu-canvas/src/lib.rs | 562 +++++++++++++----- .../tahuantinsuyu-theme/src/lib.rs | 128 +++- .../modules/ui_engine/libs/theme/src/lib.rs | 67 +++ 3 files changed, 615 insertions(+), 142 deletions(-) diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs index 68deb84..d3466b9 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs @@ -37,7 +37,7 @@ use gpui::{ Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, PathBuilder, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, SharedString, Styled, - Window, canvas, div, hsla, linear_color_stop, linear_gradient, point, prelude::*, px, + Window, canvas, div, hsla, point, prelude::*, px, }; use tahuantinsuyu_engine::{Geometry, Layer, LayerKind, OUTER_RING_MODULES, RenderModel}; @@ -324,6 +324,7 @@ impl AstrologyCanvas { cx.notify(); } + #[allow(dead_code)] fn pan_by(&mut self, dx: f32, dy: f32, cx: &mut Context<'_, Self>) { if dx == 0.0 && dy == 0.0 { return; @@ -335,11 +336,14 @@ impl AstrologyCanvas { // ----- Internos: handlers de jog-dial ----- - fn on_jog_down( + /// Despacha el LMB down entre jog-dial (sobre el anillo de signos) + /// y pan (cualquier otra parte del canvas). El jog-dial es el + /// control de rectificación de hora; el pan es navegación libre. + fn on_primary_down( &mut self, position: Point, bounds: Bounds, - _cx: &mut Context<'_, Self>, + cx: &mut Context<'_, Self>, ) { let (cx_px, cy_px) = bounds_center(bounds); let mx: f32 = position.x.into(); @@ -347,20 +351,18 @@ impl AstrologyCanvas { let dx = mx - cx_px; let dy = my - cy_px; let dist = (dx * dx + dy * dy).sqrt(); - // r_outer se deriva del width actual del canvas (que ya - // incorpora view_scale), no del WHEEL_SIZE constante. Sin esto, - // el jog-dial dejaría de funcionar al hacer zoom. let r_outer = effective_r_outer(bounds); 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 on_dial = dist >= radii.sign_inner * 0.95 && dist <= radii.sign_outer * 1.10; + if on_dial { + let angle = dy.atan2(dx).to_degrees(); + self.state.drag_jog = Some(JogDragState { + last_screen_angle_deg: angle, + accumulated_delta_deg: 0.0, + }); + } else { + self.on_pan_down(position, cx); } - 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( @@ -600,22 +602,14 @@ impl AstrologyCanvas { _w: &mut Window, cx: &mut Context<'_, Self>, ) { - let (dx_px, dy_px) = match event.delta { + let (_dx_px, dy_px) = match event.delta { ScrollDelta::Pixels(p) => (f32::from(p.x), f32::from(p.y)), ScrollDelta::Lines(p) => (p.x * 16.0, p.y * 16.0), }; - // Ctrl + wheel = zoom. wheel solo = pan (contenido sigue al - // dedo). El criterio de "modifier" usa el control flag estándar - // de gpui (en macOS sería cmd; aceptamos ambos como zoom). - let zoom_mod = event.modifiers.control || event.modifiers.platform; - if zoom_mod { - // Sensibilidad: 100px de scroll ≈ ±20% zoom. exp es suave y - // simétrico contra dy negativo (zoom out). - let factor = (dy_px * 0.002).exp(); - self.zoom_by(factor, cx); - } else { - self.pan_by(dx_px, dy_px, cx); - } + // Wheel = zoom puro, sin modifier. Pan se hace con drag (LMB + // fuera del anillo, o MMB). 100px de scroll ≈ ±20% zoom. + let factor = (dy_px * 0.002).exp(); + self.zoom_by(factor, cx); } fn on_jog_up(&mut self, cx: &mut Context<'_, Self>) { @@ -673,6 +667,58 @@ impl AstrologyCanvas { const WHEEL_SIZE: f32 = 580.0; const WHEEL_MARGIN: f32 = 28.0; +/// Pinta un starfield sutil sobre el background del panel del canvas. +/// Posiciones generadas con xorshift32 + seed const → idénticas entre +/// frames (no parpadea). Las estrellas viven solo cuando el theme es +/// dark — sobre fondos claros (impresora / solarized) un punteado de +/// puntos quedaría como ruido visual y NO suma al sentido "papel". +fn paint_starfield(bounds: Bounds, window: &mut Window, theme: &Theme) { + if !theme.is_dark { + return; + } + 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(); + if bw <= 0.0 || bh <= 0.0 { + return; + } + + // Densidad: ~1 estrella por 4800 px² → unas 130 estrellas en + // 800×800, escala con el panel. + let count = ((bw * bh) / 4800.0).clamp(40.0, 320.0) as u32; + + let mut state: u32 = 0x1f3a_5b7d; + let mut next = || -> u32 { + // xorshift32 — barato y determinístico. + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + state + }; + + let star_color = hsla(220.0 / 360.0, 0.20, 0.92, 1.0); + for _ in 0..count { + let rx = (next() as f32) / (u32::MAX as f32); + let ry = (next() as f32) / (u32::MAX as f32); + let ra = (next() as f32) / (u32::MAX as f32); + let rs = (next() as f32) / (u32::MAX as f32); + let x = ox + rx * bw; + let y = oy + ry * bh; + // Distribución de tamaños: la mayoría 0.6-1.0px ("polvo"), un + // 15% un poco más grandes (1.4-2.2px) que actúan como + // "estrellas brillantes". + let r = if rs > 0.85 { + 1.4 + rs * 0.8 + } else { + 0.6 + rs * 0.4 + }; + // Alpha entre 0.10 y 0.55 — sutil, nunca compite con la rueda. + let a = 0.10 + ra * 0.45; + fill_circle(window, x, y, r, with_alpha(star_color, a)); + } +} + fn bounds_center(bounds: Bounds) -> (f32, f32) { let ox: f32 = bounds.origin.x.into(); let oy: f32 = bounds.origin.y.into(); @@ -721,6 +767,19 @@ impl Render for AstrologyCanvas { CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items), }; + // Starfield: capa absoluta detrás del body, ocupa todo el + // canvas. Pinta ~140 puntos pequeños semi-transparentes en + // posiciones deterministas (PRNG con seed const) — sin + // parpadeo entre frames. Sutil; aporta el "universo" sin + // competir con la rueda. + let theme_for_stars = theme.clone(); + let starfield = canvas( + |_b, _w, _cx| (), + move |bounds, _, window, _| paint_starfield(bounds, window, &theme_for_stars), + ) + .absolute() + .size_full(); + div() .id("astrology-canvas-root") .track_focus(&focus) @@ -735,12 +794,18 @@ impl Render for AstrologyCanvas { .on_scroll_wheel(cx.listener(Self::on_scroll)) .size_full() .bg(theme.bg_panel.clone()) - .flex() - .flex_col() - .items_center() - .justify_center() + .relative() .overflow_hidden() - .child(body) + .child(starfield) + .child( + div() + .size_full() + .flex() + .flex_col() + .items_center() + .justify_center() + .child(body), + ) } } @@ -843,6 +908,12 @@ fn render_wheel( let mc_for_paint = render.midheaven_deg; let visibility_for_paint = visible.clone(); let entity_for_canvas = entity.clone(); + // Hover focus para el highlight de aspectos — solo cuando el hover + // es un Body (sobre un planeta), no sobre cusps ni aspectos. + let hover_focus_paint: Option = match hover { + Some(HoverInfo::Body { symbol, .. }) => Some(symbol.clone()), + _ => None, + }; let canvas_element = canvas( move |_b: Bounds, _w, _cx| (), move |bounds: Bounds, _, window, _| { @@ -858,12 +929,13 @@ fn render_wheel( rot_offset, radii, &visibility_for_paint, + hover_focus_paint.as_deref(), ); // Handlers de mouse — se registran cada frame contra el - // window; GPUI los reemplaza al re-renderear. Jog-dial (LMB - // sobre el anillo de signos) y pan (MMB en cualquier parte - // del canvas) coexisten porque consumen botones distintos. + // window; GPUI los reemplaza al re-renderear. LMB despacha + // entre jog-dial (sobre el anillo) y pan (afuera). MMB es + // pan secundario para usuarios con scroll-mouse. let entity_d = entity_for_canvas.clone(); window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| { if !bounds.contains(&ev.position) { @@ -871,8 +943,9 @@ fn render_wheel( } match ev.button { MouseButton::Left => { - entity_d - .update(cx, |this, cx| this.on_jog_down(ev.position, bounds, cx)); + entity_d.update(cx, |this, cx| { + this.on_primary_down(ev.position, bounds, cx) + }); } MouseButton::Middle => { entity_d.update(cx, |this, cx| this.on_pan_down(ev.position, cx)); @@ -915,33 +988,17 @@ fn render_wheel( .w(px(wheel_size)) .h(px(wheel_size)); - // Gradient sutil diagonal en el fondo del wheel — toque "místico - // velado". En dark la alpha es muy baja (el fondo del panel ya es - // oscuro, no hace falta tinte fuerte). En light el panel es claro, - // así que necesitamos alphas mayores para que el gradient se vea - // como un fondo "papel teñido" y no se borre contra blanco. - let (a0, a1) = if theme.is_dark { - (0.06, 0.03) - } else { - (0.18, 0.10) - }; - let wheel_bg = linear_gradient( - 155.0, - linear_color_stop(with_alpha(palette.dial_ring, a0), 0.0), - linear_color_stop(with_alpha(palette.angle_highlight, a1), 1.0), - ); - + // El wheel ya no tiene bg propio — antes era un cuadrado con + // gradient que cortaba contra el fondo del panel; ahora el panel + // (con su starfield encima en `render`) fluye continuo a través + // del área del wheel, dando el efecto de "rueda flotando en el + // universo" en lugar de "rueda sobre placa cuadrada". let mut wheel = div() .relative() .w(px(wheel_size)) .h(px(wheel_size)) - // El parent del canvas centra con flex; aplicamos el pan como - // margin shift desde ese centrado natural. Positivo = a la - // derecha / abajo; negativo desplaza al lado opuesto. .ml(px(view_pan_x)) .mt(px(view_pan_y)) - .bg(wheel_bg) - .rounded(px(12.0)) .child(canvas_element); // Sign glyphs. @@ -987,17 +1044,19 @@ fn render_wheel( } } - // Planet glyphs: natal (en `bodies`) + overlays en sus rings - // (progression, solar_arc) con alpha + tamaño más chico para - // diferenciarse visualmente del natal. + // Planet glyphs: natal en `bodies` + overlays (progression, + // solar_arc) en sus rings, ambos con disco-halo para legibilidad + // contra cualquier fondo. El natal lleva un tamaño un poco mayor + // que los overlays para que se lea como "el cuerpo principal". + let halo_bg = glyph_halo(theme); 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 alpha = if is_natal { 1.0 } else { 0.88 }; let font_size = if is_natal { 18.0 } else { 14.0 }; - let box_size = if is_natal { 24.0 } else { 20.0 }; + let disk_size = if is_natal { 26.0 } else { 22.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); @@ -1008,21 +1067,24 @@ fn render_wheel( if let Some(marker) = &g.dignity_marker { glyph_text.push_str(marker); } - wheel = wheel.child(centered_glyph( + wheel = wheel.child(planet_glyph( cx_center + x, cy_center + y, - box_size, + disk_size, font_size, glyph_text.into(), color, + halo_bg, + with_alpha(color, 0.85), )); } } } } - // Planet glyphs en el outer ring — transit o synastry, los dos - // comparten ese slot (mutuamente excluyentes a nivel de Shell). + // Planet glyphs en el outer ring — transit o synastry (slot + // compartido, mutuamente excluyentes a nivel de Shell). Disco un + // poco más chico que el natal — el outer es "secundario". if visible.get(&LayerKind::Outer).copied().unwrap_or(true) { for layer in &render.layers { if matches!(layer.kind, LayerKind::Outer) @@ -1030,19 +1092,21 @@ fn render_wheel( { 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 color = with_alpha(planet_color(palette, &g.symbol), 0.92); let glyph_text = if g.retrograde { format!("{}ᴿ", planet_unicode(&g.symbol)) } else { planet_unicode(&g.symbol).into() }; - wheel = wheel.child(centered_glyph( + wheel = wheel.child(planet_glyph( cx_center + x, cy_center + y, 20.0, - 14.0, + 13.0, glyph_text.into(), color, + halo_bg, + with_alpha(color, 0.75), )); } } @@ -1147,27 +1211,39 @@ fn render_wheel( ); } - // 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. + // Labels ASC/MC/DESC/IC como pills en el perímetro — bg del halo + // + border y texto en `angle_highlight`. Más legibles que el + // centered_glyph plano del fase anterior, en especial sobre + // fondos claros donde el ámbar/oro de angle_highlight se diluye. 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; + let label_r = r_outer * 1.08; 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, - )); + let pill_w = if label.len() > 2 { 38.0 } else { 30.0 }; + let pill_h = 18.0; + wheel = wheel.child( + div() + .absolute() + .left(px(cx_center + x - pill_w / 2.0)) + .top(px(cy_center + y - pill_h / 2.0)) + .w(px(pill_w)) + .h(px(pill_h)) + .flex() + .items_center() + .justify_center() + .rounded(px(9.0)) + .bg(halo_bg) + .border_1() + .border_color(with_alpha(palette.angle_highlight, 0.85)) + .text_size(px(11.0)) + .text_color(palette.angle_highlight) + .child(SharedString::from(label)), + ); } // --- Header + footer + indicador de tiempo --- @@ -1487,6 +1563,10 @@ impl Radii { } #[allow(clippy::too_many_arguments)] +// `hover_focus`: symbol del planeta hovereado en este frame (si lo +// hay). Las líneas de aspecto que NO tocan a ese planeta se opacan +// para que el usuario lea claramente "qué afecta a qué". Si `None`, +// todas las líneas se pintan a alpha plena. fn paint_wheel( bounds: Bounds, window: &mut Window, @@ -1498,18 +1578,23 @@ fn paint_wheel( rot_offset_deg: f32, radii: Radii, visibility: &HashMap, + hover_focus: Option<&str>, ) { 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); + // Anillos del dial con efecto 3D: highlight interior + base + + // shadow exterior. El highlight es 1 px hacia el centro con + // luminancia +0.18; la shadow 1 px hacia afuera con -0.18. + // El bevel central — varios strokes finos con alpha en bell + // curve entre sign_inner y sign_outer — da volumen al dial. + stroke_circle_3d(window, cx, cy, radii.sign_outer, 1.5, palette.dial_ring, theme); + stroke_circle_3d(window, cx, cy, radii.sign_inner, 1.0, palette.dial_ring, theme); + paint_dial_bevel(window, cx, cy, &radii, palette, theme); // Cusps zodiacales cada 30°. for i in 0..12 { @@ -1569,45 +1654,70 @@ fn paint_wheel( } } - // Asc + MC extendidos hasta el centro con opacidad sutil. - paint_radial_line( - window, - cx, - cy, + // Cruz completa Asc-Desc + MC-IC, alpha bastante visible para + // que orienten la lectura sin competir con cuerpos/aspectos. + // 4 radios desde el centro: ASC, DESC (=asc+180), MC, IC + // (=mc+180). `paint_radial_line` con r_inner=0 pinta un radio + // del centro al borde — la cruz es la unión de los 4. + let axis_color = with_alpha(palette.angle_highlight, 0.55); + for axis_deg in [ 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, + ascendant_deg + 180.0, midheaven_deg, - ascendant_deg, - rot_offset_deg, - 0.0, - radii.houses_outer, - with_alpha(palette.angle_highlight, 0.35), - 1.0, - ); + midheaven_deg + 180.0, + ] { + paint_radial_line( + window, + cx, + cy, + axis_deg, + ascendant_deg, + rot_offset_deg, + 0.0, + radii.houses_outer, + axis_color, + 1.4, + ); + } } // 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) { + let mono = palette.is_monochrome(); 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); + let base = aspect_color(palette, &seg.kind); + let base = with_alpha(base, base.a * seg.opacity); + // Hover focus: si hay un planeta hovereado y + // este segmento NO lo toca, lo atenuamos al + // 18%; si lo toca o no hay hover, va pleno. + let touches_hover = hover_focus + .map(|sym| seg.from_body == sym || seg.to_body == sym) + .unwrap_or(true); + let factor = if touches_hover { 1.0 } else { 0.18 }; + let color = with_alpha(base, base.a * factor); + let dash = if mono { + dash_pattern_for_kind(&seg.kind) + } else { + None + }; + // En BW las "fuertes" (conjunction/opposition/ + // square) van un poco más gruesas para sumar + // diferenciación al dash. + let width = if mono { + match seg.kind.as_str() { + "conjunction" | "opposition" | "square" => 1.3, + _ => 1.0, + } + } else { + 1.0 + }; if is_cross { paint_cross_aspect_line( window, @@ -1620,6 +1730,7 @@ fn paint_wheel( r_from, r_to, color, + dash, ); } else { paint_aspect_line( @@ -1632,6 +1743,8 @@ fn paint_wheel( rot_offset_deg, r_from, color, + dash, + width, ); } } @@ -1640,11 +1753,13 @@ fn paint_wheel( } } - // 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. + // 4. Marcadores de posición exacta. Antes el dot era "el planeta"; + // ahora el glyph (con halo, en DOM) lo es. El círculo acá queda + // como marker de precisión angular — chico, alpha alta, sobre el + // anillo correspondiente. Glow se mantiene para Sol/Luna como + // toque místico, pero también reducido. if show(LayerKind::Bodies) { - let dot_r = (radii.sign_outer * 0.018).max(2.0); + let dot_r = (radii.sign_outer * 0.009).max(1.5); for layer in layers { if matches!(layer.kind, LayerKind::Bodies) { let ring = radii.body_ring(&layer.module_id); @@ -1654,7 +1769,7 @@ fn paint_wheel( 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); + paint_glow(window, cx + x, cy + y, dot_r * 1.8, color); } fill_circle(window, cx + x, cy + y, dot_r, color); } @@ -1699,24 +1814,27 @@ fn paint_wheel( && OUTER_RING_MODULES.contains(&l.module_id.as_str()) }); if outer_active && show(LayerKind::Outer) { - stroke_circle( + let band = radii.sign_outer * 0.035; + stroke_circle_3d( window, cx, cy, - radii.transits + radii.sign_outer * 0.035, - 0.6, - with_alpha(palette.dial_ring, 0.4), + radii.transits + band, + 0.7, + with_alpha(palette.dial_ring, 0.55), + theme, ); - stroke_circle( + stroke_circle_3d( window, cx, cy, - radii.transits - radii.sign_outer * 0.035, - 0.6, - with_alpha(palette.dial_ring, 0.4), + radii.transits - band, + 0.7, + with_alpha(palette.dial_ring, 0.55), + theme, ); - let dot_r = (radii.sign_outer * 0.017).max(2.0); + let dot_r = (radii.sign_outer * 0.008).max(1.5); for layer in layers { if matches!(layer.kind, LayerKind::Outer) && (OUTER_RING_MODULES.contains(&layer.module_id.as_str())) @@ -1853,15 +1971,12 @@ fn paint_aspect_line( rot_offset_deg: f32, r: f32, color: Hsla, + dash: Option<(f32, f32)>, + width: f32, ) { 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); - } + paint_segment(window, cx + xa, cy + ya, cx + xb, cy + yb, color, dash, width); } /// Línea de aspecto natal ↔ tránsito: extremos en radios distintos. @@ -1880,14 +1995,82 @@ fn paint_cross_aspect_line( r_from: f32, r_to: f32, color: Hsla, + dash: Option<(f32, f32)>, ) { 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); + paint_segment(window, cx + xa, cy + ya, cx + xb, cy + yb, color, dash, 0.7); +} + +/// Pinta un segmento entre dos puntos. Si `dash` es `Some((on, off))`, +/// itera el vector pintando trechos de `on` px con gaps de `off` px. +/// Si `None`, una sola línea continua. Usado por todos los aspect +/// painters — el dash pattern es la forma de distinguir kinds en +/// el theme BW (donde el color no sirve). +fn paint_segment( + window: &mut Window, + x0: f32, + y0: f32, + x1: f32, + y1: f32, + color: Hsla, + dash: Option<(f32, f32)>, + width: f32, +) { + let Some((on, off)) = dash else { + let mut b = PathBuilder::stroke(px(width)); + b.move_to(point(px(x0), px(y0))); + b.line_to(point(px(x1), px(y1))); + if let Ok(p) = b.build() { + window.paint_path(p, color); + } + return; + }; + let dx = x1 - x0; + let dy = y1 - y0; + let len = (dx * dx + dy * dy).sqrt(); + if len < 0.1 { + return; + } + let ux = dx / len; + let uy = dy / len; + let step = on + off; + if step < 0.1 { + return; + } + let mut t = 0.0; + while t < len { + let t_end = (t + on).min(len); + let sx = x0 + ux * t; + let sy = y0 + uy * t; + let ex = x0 + ux * t_end; + let ey = y0 + uy * t_end; + let mut b = PathBuilder::stroke(px(width)); + b.move_to(point(px(sx), px(sy))); + b.line_to(point(px(ex), px(ey))); + if let Ok(p) = b.build() { + window.paint_path(p, color); + } + t += step; + } +} + +/// Dash pattern por aspecto, para modo monocromático. En modo color +/// el caller pasa `None` y las líneas van sólidas. Patterns elegidos +/// para que cada kind sea distinguible a ojo: +/// - conjunction/opposition: sólido (más peso visual, son los +/// aspectos "fuertes") +/// - square: dash medio (4 on / 3 off) +/// - trine: dash largo (8 on / 2 off) — casi sólido pero distinguible +/// - sextile: dotted (1.5 on / 3 off) +/// - minor: dotted finísimo (1 on / 4 off) +fn dash_pattern_for_kind(kind: &str) -> Option<(f32, f32)> { + match kind { + "conjunction" | "opposition" => None, + "square" => Some((4.0, 3.0)), + "trine" => Some((8.0, 2.0)), + "sextile" => Some((1.5, 3.0)), + _ => Some((1.0, 4.0)), } } @@ -1948,10 +2131,115 @@ fn centered_glyph( .child(text) } +/// Glyph de planeta con disco-halo detrás del char. El disco viene en +/// `disk_bg` (semi-opaco para que se vea a través el fondo del wheel) +/// y `disk_border` (típicamente el color del planeta). El char por +/// dentro va en `text_color` — recomendado el color del planeta sobre +/// disco neutro, o color contrastante sobre disco coloreado. +fn planet_glyph( + x: f32, + y: f32, + disk_size: f32, + font_size: f32, + text: SharedString, + text_color: Hsla, + disk_bg: Hsla, + disk_border: Hsla, +) -> gpui::Div { + div() + .absolute() + .left(px(x - disk_size / 2.0)) + .top(px(y - disk_size / 2.0)) + .w(px(disk_size)) + .h(px(disk_size)) + .rounded_full() + .bg(disk_bg) + .border_1() + .border_color(disk_border) + .flex() + .items_center() + .justify_center() + .text_size(px(font_size)) + .text_color(text_color) + .child(text) +} + +/// Color HSL semi-opaco para los halos de los glyphs — derivado del +/// theme. En dark va casi negro; en light casi blanco. Alpha alta para +/// que el char quede legible contra cualquier cosa que haya detrás +/// (anillo, líneas de aspecto, starfield). +fn glyph_halo(theme: &Theme) -> Hsla { + if theme.is_dark { + hsla(0.0, 0.0, 0.07, 0.92) + } else { + hsla(0.0, 0.0, 0.97, 0.92) + } +} + fn with_alpha(c: Hsla, a: f32) -> Hsla { hsla(c.h, c.s, c.l, a.clamp(0.0, 1.0)) } +/// Devuelve `c` con la luminancia modificada por `delta` (clamp 0..1). +/// Útil para derivar highlight (+luma) y shadow (-luma) de un color +/// base manteniendo hue y saturación — efecto bevel/3D barato. +fn adjust_luma(c: Hsla, delta: f32) -> Hsla { + hsla(c.h, c.s, (c.l + delta).clamp(0.0, 1.0), c.a) +} + +/// Stroke con efecto embossed: 3 trazos concéntricos. El highlight va +/// 0.7 px hacia el centro con luminancia subida; el principal en `r`; +/// el shadow 0.7 px hacia afuera con luminancia bajada. La dirección +/// del bevel depende del theme: en dark el highlight es exterior (luz +/// "desde arriba"), en light interior (sombra "desde arriba" hacia +/// el centro). +fn stroke_circle_3d( + window: &mut Window, + cx: f32, + cy: f32, + r: f32, + width: f32, + color: Hsla, + theme: &Theme, +) { + let (hl_offset, sh_offset) = if theme.is_dark { + (-0.7, 0.7) + } else { + (0.7, -0.7) + }; + let hl = with_alpha(adjust_luma(color, 0.20), color.a * 0.55); + let sh = with_alpha(adjust_luma(color, -0.18), color.a * 0.55); + stroke_circle(window, cx, cy, r + hl_offset, (width * 0.7).max(0.4), hl); + stroke_circle(window, cx, cy, r, width, color); + stroke_circle(window, cx, cy, r + sh_offset, (width * 0.7).max(0.4), sh); +} + +/// Bevel central del anillo de signos: ~10 strokes finos entre +/// sign_inner y sign_outer, con alpha en bell curve (máximo en el +/// medio, decae hacia los bordes). Genera la sensación de volumen +/// sin pintar gradient radial (no soportado en gpui canvas). +fn paint_dial_bevel( + window: &mut Window, + cx: f32, + cy: f32, + radii: &Radii, + palette: &AstroPalette, + theme: &Theme, +) { + let steps = 10; + let base = if theme.is_dark { 0.07 } else { 0.10 }; + let color = palette.dial_ring; + for i in 0..steps { + let t = (i as f32 + 0.5) / steps as f32; + let r = radii.sign_inner + (radii.sign_outer - radii.sign_inner) * t; + // Bell curve simétrica: |t-0.5|*2 da 0..1 desde el centro, lo + // invertimos para que el centro tenga peso máximo. + let bell = 1.0 - ((t - 0.5).abs() * 2.0); + let a = base * bell; + stroke_circle(window, cx, cy, r, 1.0, with_alpha(color, a)); + } +} + fn sign_unicode(name: &str) -> &'static str { match name { "aries" => "♈", diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs index 0086108..b7ceb94 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-theme/src/lib.rs @@ -215,14 +215,132 @@ impl AstroPalette { } } - pub fn for_theme(theme: &yahweh_theme::Theme) -> Self { - if theme.is_dark { - Self::dark() - } else { - Self::light() + /// Variante "papel coloreado" — para preview de impresión. Hue de + /// cada slot mantenido; luminancia 0.26-0.34 y saturación alta + /// para que sobreviva el ink-bleed sin perder identidad. Sin glow. + pub fn print_color() -> Self { + Self { + is_dark: false, + + fire: hsla(11.0 / 360.0, 0.78, 0.34, 1.0), + earth: hsla(95.0 / 360.0, 0.55, 0.26, 1.0), + air: hsla(48.0 / 360.0, 0.78, 0.34, 1.0), + water: hsla(210.0 / 360.0, 0.72, 0.32, 1.0), + + cardinal: hsla(340.0 / 360.0, 0.65, 0.34, 1.0), + fixed: hsla(258.0 / 360.0, 0.55, 0.32, 1.0), + mutable: hsla(170.0 / 360.0, 0.55, 0.28, 1.0), + + sun: hsla(35.0 / 360.0, 0.95, 0.34, 1.0), + moon: hsla(220.0 / 360.0, 0.35, 0.34, 1.0), + mercury: hsla(140.0 / 360.0, 0.55, 0.28, 1.0), + venus: hsla(330.0 / 360.0, 0.65, 0.36, 1.0), + mars: hsla(8.0 / 360.0, 0.85, 0.34, 1.0), + jupiter: hsla(38.0 / 360.0, 0.85, 0.34, 1.0), + saturn: hsla(28.0 / 360.0, 0.30, 0.26, 1.0), + uranus: hsla(195.0 / 360.0, 0.75, 0.34, 1.0), + neptune: hsla(225.0 / 360.0, 0.65, 0.34, 1.0), + pluto: hsla(280.0 / 360.0, 0.55, 0.28, 1.0), + chiron: hsla(75.0 / 360.0, 0.42, 0.30, 1.0), + north_node: hsla(35.0 / 360.0, 0.55, 0.36, 1.0), + south_node: hsla(35.0 / 360.0, 0.30, 0.28, 1.0), + lilith: hsla(310.0 / 360.0, 0.60, 0.26, 1.0), + + conjunction: hsla(45.0 / 360.0, 0.75, 0.32, 1.0), + sextile: hsla(195.0 / 360.0, 0.70, 0.32, 1.0), + square: hsla(8.0 / 360.0, 0.85, 0.34, 1.0), + trine: hsla(140.0 / 360.0, 0.65, 0.28, 1.0), + opposition: hsla(280.0 / 360.0, 0.65, 0.36, 1.0), + minor_aspect: hsla(220.0 / 360.0, 0.40, 0.40, 0.85), + + dial_ring: hsla(40.0 / 360.0, 0.30, 0.22, 1.0), + house_cusp: hsla(40.0 / 360.0, 0.20, 0.28, 0.90), + angle_highlight: hsla(15.0 / 360.0, 0.85, 0.36, 1.0), } } + /// Variante "papel B&N" — preview de impresión monocromática. + /// TODO los slots de planeta y aspecto se reducen a niveles de + /// gris. El canvas se encarga de diferenciar aspectos por dash + /// pattern y planetas por glyph (el unicode astronómico es + /// distintivo aunque pierda color). + pub fn print_bw() -> Self { + // Tres niveles funcionales: muy oscuro (texto, glyphs + // principales), medio (cusps, líneas), claro (fondos, minors). + let ink_strong = hsla(0.0, 0.0, 0.10, 1.0); + let ink_mid = hsla(0.0, 0.0, 0.30, 1.0); + let ink_soft = hsla(0.0, 0.0, 0.50, 0.90); + let ink_faint = hsla(0.0, 0.0, 0.55, 0.75); + + Self { + is_dark: false, + + fire: ink_strong, + earth: ink_strong, + air: ink_strong, + water: ink_strong, + + cardinal: ink_mid, + fixed: ink_mid, + mutable: ink_mid, + + // Planetas: todos en ink_strong para que los glyphs se + // lean fuerte. El usuario distingue por el unicode + // astronómico, no por hue. + sun: ink_strong, + moon: ink_strong, + mercury: ink_strong, + venus: ink_strong, + mars: ink_strong, + jupiter: ink_strong, + saturn: ink_strong, + uranus: ink_strong, + neptune: ink_strong, + pluto: ink_strong, + chiron: ink_mid, + north_node: ink_mid, + south_node: ink_mid, + lilith: ink_mid, + + // Aspectos: el color es uniforme; la diferenciación es por + // dash pattern en el painter (square=dashed, trine=solid, + // sextile=dotted, etc.). Acá solo damos el "intensity" + // base que el painter modula. + conjunction: ink_strong, + sextile: ink_mid, + square: ink_strong, + trine: ink_mid, + opposition: ink_strong, + minor_aspect: ink_faint, + + dial_ring: ink_mid, + house_cusp: ink_soft, + angle_highlight: ink_strong, + } + } + + pub fn for_theme(theme: &yahweh_theme::Theme) -> Self { + // Dispatcher por nombre para los themes "papel"; el resto cae + // al binary dark/light según `is_dark`. Mantenemos el match + // case-insensitive por defensa contra cambios de naming. + match theme.name { + "Print Color" => Self::print_color(), + "Print B&W" => Self::print_bw(), + _ if theme.is_dark => Self::dark(), + _ => Self::light(), + } + } + + /// Devuelve `true` si la paleta es monocromática — los painters + /// la usan para activar dash patterns en lugar de diferenciar + /// aspectos por color. + pub fn is_monochrome(&self) -> bool { + // Heurística simple: si conjunction y square (que en color + // siempre tienen hues distintos) tienen el mismo hue, + // estamos en BW. + (self.conjunction.h - self.square.h).abs() < 1e-3 + } + pub fn element(&self, e: Element) -> Hsla { match e { Element::Fire => self.fire, diff --git a/crates/modules/ui_engine/libs/theme/src/lib.rs b/crates/modules/ui_engine/libs/theme/src/lib.rs index 61a7f5a..f89f810 100644 --- a/crates/modules/ui_engine/libs/theme/src/lib.rs +++ b/crates/modules/ui_engine/libs/theme/src/lib.rs @@ -147,6 +147,8 @@ impl Theme { Self::flat_dark(), Self::solarized_light(), Self::high_contrast(), + Self::print_color(), + Self::print_bw(), ] } @@ -369,6 +371,71 @@ impl Theme { } } + /// **Print Color** — preview de impresión a color sobre papel. + /// Fondo crema cálido (#f7f4ea-ish), texto y ornamentos en + /// luminancias bajas para que sobrevivan ink-bleed. Sin gradientes + /// (los gradients no imprimen bien) y sin glow. + pub fn print_color() -> Self { + let bg_app: Background = hsla(42.0 / 360.0, 0.30, 0.94, 1.0).into(); + let bg_panel: Background = hsla(40.0 / 360.0, 0.25, 0.97, 1.0).into(); + let bg_panel_alt: Background = hsla(40.0 / 360.0, 0.20, 0.92, 1.0).into(); + Self { + name: "Print Color", + is_dark: false, + bg_app, + bg_panel, + bg_panel_alt, + bg_row_hover: hsla(40.0 / 360.0, 0.30, 0.86, 0.70), + bg_row_active: hsla(35.0 / 360.0, 0.45, 0.78, 0.85), + fg_text: hsla(30.0 / 360.0, 0.15, 0.18, 1.0), + fg_muted: hsla(30.0 / 360.0, 0.12, 0.40, 1.0), + fg_disabled: hsla(30.0 / 360.0, 0.08, 0.62, 1.0), + accent: hsla(15.0 / 360.0, 0.70, 0.40, 1.0), + accent_strong: hsla(355.0 / 360.0, 0.78, 0.36, 1.0), + border: hsla(40.0 / 360.0, 0.22, 0.82, 1.0), + border_strong: hsla(30.0 / 360.0, 0.30, 0.55, 1.0), + marker_palette: vec![ + hsla(15.0 / 360.0, 0.70, 0.35, 0.30), + hsla(210.0 / 360.0, 0.65, 0.35, 0.30), + hsla(140.0 / 360.0, 0.55, 0.30, 0.30), + hsla(285.0 / 360.0, 0.55, 0.38, 0.30), + hsla(40.0 / 360.0, 0.85, 0.40, 0.30), + ], + } + } + + /// **Print B&W** — preview de impresión monocromática. Fondo + /// blanco puro, todo en escala de grises. Cualquier slot que + /// dependa de "color" en widgets astrológicos se diferencia por + /// forma o por dash pattern, no por tinte. + pub fn print_bw() -> Self { + let bg_app: Background = hsla(0.0, 0.0, 1.00, 1.0).into(); + let bg_panel: Background = hsla(0.0, 0.0, 0.99, 1.0).into(); + let bg_panel_alt: Background = hsla(0.0, 0.0, 0.95, 1.0).into(); + Self { + name: "Print B&W", + is_dark: false, + bg_app, + bg_panel, + bg_panel_alt, + bg_row_hover: hsla(0.0, 0.0, 0.88, 0.85), + bg_row_active: hsla(0.0, 0.0, 0.78, 0.95), + fg_text: hsla(0.0, 0.0, 0.10, 1.0), + fg_muted: hsla(0.0, 0.0, 0.40, 1.0), + fg_disabled: hsla(0.0, 0.0, 0.65, 1.0), + accent: hsla(0.0, 0.0, 0.20, 1.0), + accent_strong: hsla(0.0, 0.0, 0.05, 1.0), + border: hsla(0.0, 0.0, 0.80, 1.0), + border_strong: hsla(0.0, 0.0, 0.40, 1.0), + marker_palette: vec![ + hsla(0.0, 0.0, 0.30, 0.35), + hsla(0.0, 0.0, 0.50, 0.35), + hsla(0.0, 0.0, 0.20, 0.35), + hsla(0.0, 0.0, 0.60, 0.35), + ], + } + } + /// **High Contrast** — accesibilidad. Negro puro con texto blanco y /// ornamentos amarillo/verde fuertes. Suficientemente diferente para /// notar inmediatamente al usar el switcher.