feat(gioser/web): fix mobile swipe, taskbar agnóstica, trazos zodiacales
Mobile drag fix (vista-web):
- pointermove listener ahora con `AddEventListenerOptions { passive: false }`.
Sin esto, en navegadores móviles `preventDefault()` es no-op y el browser
se traga el gesto horizontal como pan/scroll antes de que JS pueda
detectar la dirección y capturar el pointer.
- CSS: `.deck-strip` y `.deck-strip *` y `.deck-page` con
`touch-action: pan-y`. El touch-action del target inmediato es lo que
el browser consulta; sin esto, sobre un <p> dentro del strip el browser
asume `auto` y reclama horizontal.
Taskbar agnóstica (barra-web):
- Nuevo crate `crates/modules/barra/barra-web` que maneja sólo el LIST
dinámico de tareas; el resto del layout (home, brand, credits) es del
host. Misma filosofía que vista-web: separar lo reusable.
- API: Task::new(id, label).active() builder; TaskList::mount(ul) +
set_tasks/on_click/task_center. Click delegado, callback recibe
(id, cx, cy) en CSS pixels para origin de animaciones.
- Sanitiza IDs a [a-zA-Z0-9_-] y HTML-escapa labels.
- 3 tests unitarios.
- gioser-web refactoreado para consumir TaskList: sync_taskbar arma
Vec<Task> y delega; on_click del taskbar dispara minimize/restore_from_tab
según estado. install_taskbar reducido a sólo home buttons.
Trazos zodiacales (gioser-shaders + canvas-web):
- 12 líneas radiales muy sutiles entre la chacana y el aro principal, una
por signo, con colores significativos:
Aries→fuego rojo, Tauro→tierra verde, Géminis→aire amarillo,
Cáncer→agua plata, Leo→fuego dorado, Virgo→tierra marrón,
Libra→aire rosa, Escorpio→agua rojo profundo, Sagitario→fuego púrpura,
Capricornio→tierra verde oscuro, Acuario→aire celeste, Piscis→agua
verde mar.
- Aries empieza en el norte, giran en sentido horario (rueda zodiacal
clásica). Banda radial r∈[1.05*L, 0.96*ringR_main], gauss angular
con σ=0.0042 rad (~0.24° de ancho), multiplier 0.55 → apenas visible.
- Uniform `vec3 u_zodiac[12]` subido como array plano de 36 floats vía
uniform3fv. Constante ZODIAC_COLORS expuesta en canvas-web por si otros
callers la quieren.
Workspace verde + 21 tests (geom 6 + palette 4 + physics 3 + pluma-md 5
+ barra-web 3).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Generated
+10
@@ -885,6 +885,15 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "barra-web"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base-x"
|
name = "base-x"
|
||||||
version = "0.2.11"
|
version = "0.2.11"
|
||||||
@@ -3948,6 +3957,7 @@ version = "0.1.0"
|
|||||||
name = "gioser-web"
|
name = "gioser-web"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"barra-web",
|
||||||
"gioser-canvas-web",
|
"gioser-canvas-web",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"pluma-reader-web",
|
"pluma-reader-web",
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ members = [
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
"crates/modules/vista/vista-web",
|
"crates/modules/vista/vista-web",
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# modules/barra/ — taskbar agnóstica estilo Windows
|
||||||
|
# ============================================================
|
||||||
|
"crates/modules/barra/barra-web",
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# apps/ — apps que consumen el protocolo (yahweh modules+shell)
|
# apps/ — apps que consumen el protocolo (yahweh modules+shell)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"]
|
|||||||
gioser-canvas-web = { path = "../../modules/gioser/gioser-canvas-web" }
|
gioser-canvas-web = { path = "../../modules/gioser/gioser-canvas-web" }
|
||||||
pluma-reader-web = { path = "../../modules/pluma/pluma-reader-web" }
|
pluma-reader-web = { path = "../../modules/pluma/pluma-reader-web" }
|
||||||
vista-web = { path = "../../modules/vista/vista-web" }
|
vista-web = { path = "../../modules/vista/vista-web" }
|
||||||
|
barra-web = { path = "../../modules/barra/barra-web" }
|
||||||
wasm-bindgen.workspace = true
|
wasm-bindgen.workspace = true
|
||||||
wasm-bindgen-futures.workspace = true
|
wasm-bindgen-futures.workspace = true
|
||||||
js-sys.workspace = true
|
js-sys.workspace = true
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use barra_web::{Task, TaskList};
|
||||||
use gioser_canvas_web::{tips, Renderer};
|
use gioser_canvas_web::{tips, Renderer};
|
||||||
use pluma_reader_web::Reader;
|
use pluma_reader_web::Reader;
|
||||||
use vista_web::Deck;
|
use vista_web::Deck;
|
||||||
@@ -48,6 +49,7 @@ struct DeckState {
|
|||||||
struct AppState {
|
struct AppState {
|
||||||
document: Document,
|
document: Document,
|
||||||
deck: Deck,
|
deck: Deck,
|
||||||
|
taskbar: TaskList,
|
||||||
state: RefCell<DeckState>,
|
state: RefCell<DeckState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,22 +197,18 @@ impl AppState {
|
|||||||
|
|
||||||
fn sync_taskbar(&self) {
|
fn sync_taskbar(&self) {
|
||||||
let s = self.state.borrow();
|
let s = self.state.borrow();
|
||||||
if let Some(list) = self.document.get_element_by_id("taskbar-list") {
|
let tasks: Vec<Task> = s
|
||||||
let mut html = String::new();
|
.pages
|
||||||
for e in &s.pages {
|
.iter()
|
||||||
let label = e.to_uppercase();
|
.map(|e| {
|
||||||
let active = if s.active.as_deref() == Some(e.as_str()) {
|
let mut t = Task::new(e.clone(), e.to_uppercase());
|
||||||
" active"
|
if s.active.as_deref() == Some(e.as_str()) {
|
||||||
} else {
|
t = t.active();
|
||||||
""
|
}
|
||||||
};
|
t
|
||||||
html.push_str(&format!(
|
})
|
||||||
"<li><button class=\"taskbar-item{active}\" data-task=\"{e}\" type=\"button\">\
|
.collect();
|
||||||
<span class=\"taskbar-item-dot\" aria-hidden=\"true\"></span>{label}</button></li>"
|
self.taskbar.set_tasks(&tasks);
|
||||||
));
|
|
||||||
}
|
|
||||||
list.set_inner_html(&html);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_page_dom(&self, element: &str) {
|
fn ensure_page_dom(&self, element: &str) {
|
||||||
@@ -294,8 +292,7 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn taskbar_item_center(&self, element: &str) -> Option<(f64, f64)> {
|
fn taskbar_item_center(&self, element: &str) -> Option<(f64, f64)> {
|
||||||
let sel = format!(".taskbar-item[data-task=\"{}\"]", element);
|
self.taskbar.task_center(element)
|
||||||
self.element_center(&sel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn viewport_height(&self) -> f64 {
|
fn viewport_height(&self) -> f64 {
|
||||||
@@ -337,9 +334,17 @@ pub fn boot() -> Result<(), JsValue> {
|
|||||||
.dyn_into()?;
|
.dyn_into()?;
|
||||||
let deck = Deck::mount(strip_el)?;
|
let deck = Deck::mount(strip_el)?;
|
||||||
|
|
||||||
|
// Mount barra-web taskbar (manages the dynamic task list).
|
||||||
|
let list_el: HtmlElement = document
|
||||||
|
.get_element_by_id("taskbar-list")
|
||||||
|
.ok_or_else(|| JsValue::from_str("no #taskbar-list"))?
|
||||||
|
.dyn_into()?;
|
||||||
|
let taskbar = TaskList::mount(list_el)?;
|
||||||
|
|
||||||
let app = Rc::new(AppState {
|
let app = Rc::new(AppState {
|
||||||
document: document.clone(),
|
document: document.clone(),
|
||||||
deck: deck.clone(),
|
deck: deck.clone(),
|
||||||
|
taskbar: taskbar.clone(),
|
||||||
state: RefCell::default(),
|
state: RefCell::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -351,6 +356,19 @@ pub fn boot() -> Result<(), JsValue> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// barra on_click → restore / toggle minimize del app
|
||||||
|
{
|
||||||
|
let app2 = app.clone();
|
||||||
|
taskbar.on_click(move |id, cx, cy| {
|
||||||
|
let is_active = app2.state.borrow().active.as_deref() == Some(id);
|
||||||
|
if is_active {
|
||||||
|
app2.minimize(cx, cy);
|
||||||
|
} else {
|
||||||
|
app2.restore_from_tab(id, cx, cy);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
install_resize(&window, &canvas, &renderer)?;
|
install_resize(&window, &canvas, &renderer)?;
|
||||||
install_mouse(&document, &canvas, &renderer)?;
|
install_mouse(&document, &canvas, &renderer)?;
|
||||||
install_canvas_pointer(&canvas, &renderer)?;
|
install_canvas_pointer(&canvas, &renderer)?;
|
||||||
@@ -517,8 +535,9 @@ fn center_of_element(el: &Element) -> (f64, f64) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Home button + brand link (data-home) — toda la lógica de tabs vive en
|
||||||
|
/// barra-web::TaskList. Acá sólo se instalan los handlers para [data-home].
|
||||||
fn install_taskbar(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
|
fn install_taskbar(document: &Document, app: &Rc<AppState>) -> Result<(), JsValue> {
|
||||||
// Home button & brand link comparten data-home — minimizan todo.
|
|
||||||
let homes = document.query_selector_all("[data-home]")?;
|
let homes = document.query_selector_all("[data-home]")?;
|
||||||
for i in 0..homes.length() {
|
for i in 0..homes.length() {
|
||||||
let Some(node) = homes.item(i) else { continue };
|
let Some(node) = homes.item(i) else { continue };
|
||||||
@@ -533,34 +552,6 @@ fn install_taskbar(document: &Document, app: &Rc<AppState>) -> Result<(), JsValu
|
|||||||
el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
|
el.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
|
||||||
cb.forget();
|
cb.forget();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delegación en la lista de tabs.
|
|
||||||
if let Some(list) = document.get_element_by_id("taskbar-list") {
|
|
||||||
let app2 = app.clone();
|
|
||||||
let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
|
|
||||||
let Some(target) = e.target() else { return };
|
|
||||||
let Ok(target_el): Result<Element, _> = target.dyn_into() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Ok(Some(item)) = target_el.closest(".taskbar-item") else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Some(task) = item.get_attribute("data-task") {
|
|
||||||
let rect = item.get_bounding_client_rect();
|
|
||||||
let cx = rect.left() + rect.width() / 2.0;
|
|
||||||
let cy = rect.top() + rect.height() / 2.0;
|
|
||||||
// Si la pestaña ya está activa, minimiza (toggle estilo Windows).
|
|
||||||
let is_active = app2.state.borrow().active.as_deref() == Some(&task);
|
|
||||||
if is_active {
|
|
||||||
app2.minimize(cx, cy);
|
|
||||||
} else {
|
|
||||||
app2.restore_from_tab(&task, cx, cy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
list.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
|
|
||||||
cb.forget();
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,11 @@ body.deck-active-fuego .deck { --deck-glow: rgba(245, 144, 86, 0.28); }
|
|||||||
body.deck-active-agua .deck { --deck-glow: rgba(108, 208, 243, 0.22); }
|
body.deck-active-agua .deck { --deck-glow: rgba(108, 208, 243, 0.22); }
|
||||||
body.deck-active-tierra .deck { --deck-glow: rgba(212, 152, 115, 0.24); }
|
body.deck-active-tierra .deck { --deck-glow: rgba(212, 152, 115, 0.24); }
|
||||||
|
|
||||||
/* Strip horizontal con páginas — vista-web traslada esto. */
|
/* Strip horizontal con páginas — vista-web traslada esto.
|
||||||
|
touch-action: pan-y declara al browser "yo manejo horizontal, el
|
||||||
|
vertical (scroll interno de cada página) lo dejas pasar". Sin esto
|
||||||
|
el navegador móvil se traga el gesto horizontal antes de que JS
|
||||||
|
pueda capturarlo. */
|
||||||
.deck-strip {
|
.deck-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -194,6 +198,13 @@ body.deck-active-tierra .deck { --deck-glow: rgba(212, 152, 115, 0.24); }
|
|||||||
transform: translate3d(var(--vista-offset, 0px), 0, 0);
|
transform: translate3d(var(--vista-offset, 0px), 0, 0);
|
||||||
transition: transform 360ms var(--ease-page);
|
transition: transform 360ms var(--ease-page);
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
/* Asegurar que TODOS los descendientes del strip hereden el contrato
|
||||||
|
touch-action — si el toque llega a un párrafo o <a>, el browser
|
||||||
|
chequea el touch-action del target, no del padre. */
|
||||||
|
.deck-strip * {
|
||||||
|
touch-action: pan-y;
|
||||||
}
|
}
|
||||||
.deck-strip.vista-dragging,
|
.deck-strip.vista-dragging,
|
||||||
.deck-strip.vista-instant {
|
.deck-strip.vista-instant {
|
||||||
@@ -207,9 +218,7 @@ body.deck-active-tierra .deck { --deck-glow: rgba(212, 152, 115, 0.24); }
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
/* contenido alineado al pop-snap mismo si la lista de páginas viviera
|
touch-action: pan-y;
|
||||||
dentro de scroll nativo; con vista-web esto es informativo nomás. */
|
|
||||||
scroll-snap-align: start;
|
|
||||||
}
|
}
|
||||||
.deck-page[data-element="aire"] { --page-accent: var(--aire); }
|
.deck-page[data-element="aire"] { --page-accent: var(--aire); }
|
||||||
.deck-page[data-element="fuego"] { --page-accent: var(--fuego); }
|
.deck-page[data-element="fuego"] { --page-accent: var(--fuego); }
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "barra-web"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wasm-bindgen.workspace = true
|
||||||
|
js-sys.workspace = true
|
||||||
|
|
||||||
|
[dependencies.web-sys]
|
||||||
|
workspace = true
|
||||||
|
features = [
|
||||||
|
"Window",
|
||||||
|
"Document",
|
||||||
|
"Element",
|
||||||
|
"HtmlElement",
|
||||||
|
"DomRect",
|
||||||
|
"Event",
|
||||||
|
"EventTarget",
|
||||||
|
"MouseEvent",
|
||||||
|
]
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
//! Barra-web — taskbar estilo Windows, agnóstica del dominio.
|
||||||
|
//!
|
||||||
|
//! Maneja la lista dinámica de "tareas" (cajitas, una por ventana abierta)
|
||||||
|
//! dentro de un elemento `<ul>` provisto por el host. El layout del resto
|
||||||
|
//! de la barra (home button, brand, créditos, dividers, etc.) es
|
||||||
|
//! responsabilidad del host — el módulo sólo se encarga del LIST + CLICK.
|
||||||
|
//!
|
||||||
|
//! Contrato HTML mínimo:
|
||||||
|
//! ```html
|
||||||
|
//! <ul id="my-tasks" class="taskbar-list" role="presentation"></ul>
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Convenciones de clase generadas:
|
||||||
|
//! - `.taskbar-item` — cada cajita
|
||||||
|
//! - `.taskbar-item.active` — la cajita visible/foreground
|
||||||
|
//! - `.taskbar-item-dot` — punto decorativo dentro de la cajita
|
||||||
|
//! - `data-task="<id>"` — identificador único usable por CSS para theming
|
||||||
|
//! (`.taskbar-item[data-task="aire"] { --task-color: ... }`)
|
||||||
|
//!
|
||||||
|
//! El módulo NO inyecta CSS — el host estiliza estas clases.
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! let list: HtmlElement = doc.get_element_by_id("my-tasks")?.dyn_into()?;
|
||||||
|
//! let bar = barra_web::TaskList::mount(list)?;
|
||||||
|
//! bar.set_tasks(&[
|
||||||
|
//! Task::new("aire", "AIRE"),
|
||||||
|
//! Task::new("fuego", "FUEGO").active(),
|
||||||
|
//! ]);
|
||||||
|
//! bar.on_click(|id, cx, cy| {
|
||||||
|
//! // El click cayó en la cajita `id`. (cx, cy) es el centro de la
|
||||||
|
//! // cajita en CSS pixels — útil como origin de animaciones.
|
||||||
|
//! });
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::{Element, HtmlElement, MouseEvent};
|
||||||
|
|
||||||
|
/// Una tarea (cajita) en la barra.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Task {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Task {
|
||||||
|
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
label: label.into(),
|
||||||
|
active: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active(mut self) -> Self {
|
||||||
|
self.active = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TaskList {
|
||||||
|
list: HtmlElement,
|
||||||
|
on_click: Rc<RefCell<Option<Box<dyn FnMut(&str, f64, f64)>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskList {
|
||||||
|
/// Monta el módulo sobre el elemento `<ul>` provisto. Instala un único
|
||||||
|
/// listener de click delegado: cualquier click dentro del list que caiga
|
||||||
|
/// sobre un `.taskbar-item` dispara `on_click(id, cx, cy)`.
|
||||||
|
pub fn mount(list: HtmlElement) -> Result<Self, JsValue> {
|
||||||
|
let on_click: Rc<RefCell<Option<Box<dyn FnMut(&str, f64, f64)>>>> =
|
||||||
|
Rc::new(RefCell::new(None));
|
||||||
|
let on_click2 = on_click.clone();
|
||||||
|
let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
|
||||||
|
let Some(target) = e.target() else { return };
|
||||||
|
let Ok(target_el): Result<Element, _> = target.dyn_into() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(Some(item)) = target_el.closest(".taskbar-item") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(id) = item.get_attribute("data-task") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let rect = item.get_bounding_client_rect();
|
||||||
|
let cx = rect.left() + rect.width() / 2.0;
|
||||||
|
let cy = rect.top() + rect.height() / 2.0;
|
||||||
|
if let Some(cb) = on_click2.borrow_mut().as_mut() {
|
||||||
|
cb(&id, cx, cy);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
list.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
|
||||||
|
cb.forget();
|
||||||
|
Ok(Self { list, on_click })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reemplaza el contenido de la lista con las tareas dadas.
|
||||||
|
/// Los IDs se filtran a `[a-zA-Z0-9_-]` para uso seguro en atributos.
|
||||||
|
/// Los labels se HTML-escapan.
|
||||||
|
pub fn set_tasks(&self, tasks: &[Task]) {
|
||||||
|
let mut html = String::new();
|
||||||
|
for t in tasks {
|
||||||
|
let id_safe = sanitize_attr(&t.id);
|
||||||
|
let label_safe = escape_text(&t.label);
|
||||||
|
let active_cls = if t.active { " active" } else { "" };
|
||||||
|
html.push_str(&format!(
|
||||||
|
"<li><button class=\"taskbar-item{active_cls}\" data-task=\"{id_safe}\" type=\"button\">\
|
||||||
|
<span class=\"taskbar-item-dot\" aria-hidden=\"true\"></span>{label_safe}</button></li>"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.list.set_inner_html(&html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registra (o reemplaza) el callback al click sobre una cajita.
|
||||||
|
/// El callback recibe `(id, center_x, center_y)` en CSS pixels.
|
||||||
|
pub fn on_click<F: FnMut(&str, f64, f64) + 'static>(&self, cb: F) {
|
||||||
|
*self.on_click.borrow_mut() = Some(Box::new(cb));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Centro en CSS pixels de la cajita con `id` dado, o `None` si no existe.
|
||||||
|
pub fn task_center(&self, id: &str) -> Option<(f64, f64)> {
|
||||||
|
let sel = format!(".taskbar-item[data-task=\"{}\"]", sanitize_attr(id));
|
||||||
|
let el = self.list.query_selector(&sel).ok().flatten()?;
|
||||||
|
let rect = el.get_bounding_client_rect();
|
||||||
|
Some((
|
||||||
|
rect.left() + rect.width() / 2.0,
|
||||||
|
rect.top() + rect.height() / 2.0,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acceso al elemento `<ul>` host por si el caller quiere modificar
|
||||||
|
/// styling o ARIA atributos directamente.
|
||||||
|
pub fn list_el(&self) -> &HtmlElement {
|
||||||
|
&self.list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_attr(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_text(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'&' => out.push_str("&"),
|
||||||
|
'<' => out.push_str("<"),
|
||||||
|
'>' => out.push_str(">"),
|
||||||
|
'"' => out.push_str("""),
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn task_builder_defaults_inactive() {
|
||||||
|
let t = Task::new("aire", "AIRE");
|
||||||
|
assert!(!t.active);
|
||||||
|
let t2 = Task::new("fuego", "FUEGO").active();
|
||||||
|
assert!(t2.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_attr_removes_unsafe_chars() {
|
||||||
|
assert_eq!(sanitize_attr("aire"), "aire");
|
||||||
|
assert_eq!(sanitize_attr("a-b_c"), "a-b_c");
|
||||||
|
assert_eq!(sanitize_attr("ai<re>"), "aire");
|
||||||
|
assert_eq!(sanitize_attr("a\"b"), "ab");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_text_escapes_html() {
|
||||||
|
assert_eq!(escape_text("AIRE"), "AIRE");
|
||||||
|
assert_eq!(escape_text("<script>"), "<script>");
|
||||||
|
assert_eq!(escape_text("a & b"), "a & b");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,30 @@ pub mod tips {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Colores zodiacales en orden Aries→Piscis. Sigue la asignación tradicional
|
||||||
|
/// por triplicidad elemental:
|
||||||
|
/// fuego: aries, leo, sagitario (rojo, dorado, púrpura)
|
||||||
|
/// tierra: tauro, virgo, capricornio (verde, marrón, verde oscuro)
|
||||||
|
/// aire: géminis, libra, acuario (amarillo, rosa, celeste)
|
||||||
|
/// agua: cáncer, escorpio, piscis (plata, rojo profundo, verde mar)
|
||||||
|
///
|
||||||
|
/// El shader los recibe como `uniform vec3 u_zodiac[12]` y los dibuja como
|
||||||
|
/// trazos radiales muy sutiles entre la chacana y el aro exterior.
|
||||||
|
pub const ZODIAC_COLORS: [[f32; 3]; 12] = [
|
||||||
|
[0.95, 0.30, 0.20], // 0 Aries — fuego rojo
|
||||||
|
[0.35, 0.65, 0.30], // 1 Tauro — tierra verde
|
||||||
|
[0.95, 0.85, 0.30], // 2 Géminis — aire amarillo
|
||||||
|
[0.80, 0.88, 0.95], // 3 Cáncer — agua plata
|
||||||
|
[0.98, 0.65, 0.20], // 4 Leo — fuego dorado
|
||||||
|
[0.62, 0.50, 0.32], // 5 Virgo — tierra marrón
|
||||||
|
[0.95, 0.65, 0.82], // 6 Libra — aire rosa
|
||||||
|
[0.55, 0.15, 0.22], // 7 Escorpio — agua rojo profundo
|
||||||
|
[0.60, 0.30, 0.85], // 8 Sagitario — fuego púrpura
|
||||||
|
[0.22, 0.45, 0.28], // 9 Capricornio — tierra verde oscuro
|
||||||
|
[0.48, 0.78, 0.95], // 10 Acuario — aire celeste
|
||||||
|
[0.22, 0.72, 0.62], // 11 Piscis — agua verde mar
|
||||||
|
];
|
||||||
|
|
||||||
pub struct Renderer {
|
pub struct Renderer {
|
||||||
gl: GL,
|
gl: GL,
|
||||||
cosmos_prog: Program,
|
cosmos_prog: Program,
|
||||||
@@ -201,6 +225,7 @@ impl Renderer {
|
|||||||
"u_fuego_color",
|
"u_fuego_color",
|
||||||
"u_tierra_color",
|
"u_tierra_color",
|
||||||
"u_agua_color",
|
"u_agua_color",
|
||||||
|
"u_zodiac[0]",
|
||||||
"u_sun_pulse",
|
"u_sun_pulse",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -436,6 +461,17 @@ impl Renderer {
|
|||||||
self.chacana_prog.u("u_agua_color"),
|
self.chacana_prog.u("u_agua_color"),
|
||||||
gioser_palette::elements::AGUA,
|
gioser_palette::elements::AGUA,
|
||||||
);
|
);
|
||||||
|
// Subir las 12 colores zodiacales como vec3[12]. Aplanamos a un único
|
||||||
|
// slice de 36 floats; uniform3fv interpreta cada terna como vec3.
|
||||||
|
if let Some(u) = self.chacana_prog.u("u_zodiac[0]") {
|
||||||
|
let mut flat = [0.0f32; 36];
|
||||||
|
for (i, c) in ZODIAC_COLORS.iter().enumerate() {
|
||||||
|
flat[i * 3] = c[0];
|
||||||
|
flat[i * 3 + 1] = c[1];
|
||||||
|
flat[i * 3 + 2] = c[2];
|
||||||
|
}
|
||||||
|
gl.uniform3fv_with_f32_array(Some(u), &flat);
|
||||||
|
}
|
||||||
if let Some(u) = self.chacana_prog.u("u_sun_pulse") {
|
if let Some(u) = self.chacana_prog.u("u_sun_pulse") {
|
||||||
gl.uniform1f(Some(u), self.sun_pulse);
|
gl.uniform1f(Some(u), self.sun_pulse);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ uniform vec3 u_aire_color;
|
|||||||
uniform vec3 u_fuego_color;
|
uniform vec3 u_fuego_color;
|
||||||
uniform vec3 u_tierra_color;
|
uniform vec3 u_tierra_color;
|
||||||
uniform vec3 u_agua_color;
|
uniform vec3 u_agua_color;
|
||||||
|
uniform vec3 u_zodiac[12];
|
||||||
uniform float u_sun_pulse;
|
uniform float u_sun_pulse;
|
||||||
|
|
||||||
const float PI = 3.14159265;
|
const float PI = 3.14159265;
|
||||||
@@ -361,6 +362,43 @@ void main() {
|
|||||||
particles += element_particles(p, vec2(0.0, -L), vec2(0.0, -1.0), u_tierra_color, 2, 3.11);
|
particles += element_particles(p, vec2(0.0, -L), vec2(0.0, -1.0), u_tierra_color, 2, 3.11);
|
||||||
particles += element_particles(p, vec2(-L, 0.0), vec2(-1.0, 0.0), u_agua_color, 3, 5.97);
|
particles += element_particles(p, vec2(-L, 0.0), vec2(-1.0, 0.0), u_agua_color, 3, 5.97);
|
||||||
|
|
||||||
|
// === TRAZOS ZODIACALES ===
|
||||||
|
// 12 líneas radiales muy sutiles entre la chacana y el aro principal,
|
||||||
|
// una por signo, con sus colores significativos (Aries=fuego rojo,
|
||||||
|
// Tauro=tierra verde, Géminis=aire amarillo, Cáncer=agua plata, ...).
|
||||||
|
// Aries arranca en el norte y giran en sentido horario (rueda zodiacal
|
||||||
|
// clásica).
|
||||||
|
vec3 zodiac = vec3(0.0);
|
||||||
|
{
|
||||||
|
float seg = 2.0 * PI / 12.0;
|
||||||
|
// delta = ángulo medido desde el norte, en sentido horario, en [0, 2π).
|
||||||
|
float delta = (PI * 0.5) - ang;
|
||||||
|
delta = mod(delta + 8.0 * PI, 2.0 * PI);
|
||||||
|
// Índice del signo más cercano.
|
||||||
|
float k_round = mod(floor(delta / seg + 0.5), 12.0);
|
||||||
|
int k = int(k_round);
|
||||||
|
// Distancia angular al centro del segmento de ese signo.
|
||||||
|
float center_delta = k_round * seg;
|
||||||
|
float ang_diff = delta - center_delta;
|
||||||
|
// Wrap a (-π, π].
|
||||||
|
if (ang_diff > PI) ang_diff -= 2.0 * PI;
|
||||||
|
if (ang_diff < -PI) ang_diff += 2.0 * PI;
|
||||||
|
float ang_dist = abs(ang_diff);
|
||||||
|
|
||||||
|
// Línea fina, gaussiana.
|
||||||
|
float lineW = 0.0042;
|
||||||
|
float line = exp(-(ang_dist * ang_dist) / (2.0 * lineW * lineW));
|
||||||
|
|
||||||
|
// Banda radial: arranca un poco fuera de la punta de la chacana y
|
||||||
|
// termina antes del aro principal.
|
||||||
|
float r_inner = u_arm_extent * 1.05;
|
||||||
|
float r_outer = ringR_main * 0.96;
|
||||||
|
float band = smoothstep(r_inner, r_inner + 0.035, r)
|
||||||
|
* (1.0 - smoothstep(r_outer - 0.035, r_outer, r));
|
||||||
|
|
||||||
|
zodiac = u_zodiac[k] * line * band;
|
||||||
|
}
|
||||||
|
|
||||||
// === COMPOSICIÓN ===
|
// === COMPOSICIÓN ===
|
||||||
vec3 col = vec3(0.0);
|
vec3 col = vec3(0.0);
|
||||||
// Sol detrás (clip a interior).
|
// Sol detrás (clip a interior).
|
||||||
@@ -375,10 +413,13 @@ void main() {
|
|||||||
col += u_rim_color * ring_inner * 1.05;
|
col += u_rim_color * ring_inner * 1.05;
|
||||||
col += u_line_color * dots * 1.85;
|
col += u_line_color * dots * 1.85;
|
||||||
col += particles * 1.25;
|
col += particles * 1.25;
|
||||||
|
col += zodiac * 0.55; // muy sutil — apenas visible.
|
||||||
|
|
||||||
|
float zodiac_lum = zodiac.r + zodiac.g + zodiac.b;
|
||||||
float alpha = clamp(
|
float alpha = clamp(
|
||||||
halo * inside + line + glow + ring_main + ring_inner + dots + inside * 0.12
|
halo * inside + line + glow + ring_main + ring_inner + dots + inside * 0.12
|
||||||
+ (particles.r + particles.g + particles.b) * 0.5,
|
+ (particles.r + particles.g + particles.b) * 0.5
|
||||||
|
+ zodiac_lum * 0.3,
|
||||||
0.0, 1.0);
|
0.0, 1.0);
|
||||||
fragColor = vec4(col, alpha);
|
fragColor = vec4(col, alpha);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,4 +23,5 @@ features = [
|
|||||||
"EventTarget",
|
"EventTarget",
|
||||||
"PointerEvent",
|
"PointerEvent",
|
||||||
"MouseEvent",
|
"MouseEvent",
|
||||||
|
"AddEventListenerOptions",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -189,10 +189,20 @@ fn install_pointermove(
|
|||||||
let _ = strip2
|
let _ = strip2
|
||||||
.style()
|
.style()
|
||||||
.set_property("--vista-offset", &format!("{}px", offset));
|
.set_property("--vista-offset", &format!("{}px", offset));
|
||||||
|
// CRÍTICO en móvil: con listener passive el preventDefault sería
|
||||||
|
// un no-op y el browser se llevaría el gesto como pan/scroll.
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
strip.add_event_listener_with_callback("pointermove", cb.as_ref().unchecked_ref())?;
|
// `passive: false` es la diferencia entre que el swipe funcione o no en
|
||||||
|
// navegadores móviles. Default = passive, donde preventDefault no aplica.
|
||||||
|
let opts = web_sys::AddEventListenerOptions::new();
|
||||||
|
opts.set_passive(false);
|
||||||
|
strip.add_event_listener_with_callback_and_add_event_listener_options(
|
||||||
|
"pointermove",
|
||||||
|
cb.as_ref().unchecked_ref(),
|
||||||
|
&opts,
|
||||||
|
)?;
|
||||||
cb.forget();
|
cb.forget();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user