⎇ 7728013012444c20c7fe7c4248c0bc7c296f43f7
crates/modules/barra/barra-web/src/lib.rs
History
Who touched what
raw
feat(gioser/web): fix mobile swipe, taskbar agnóstica, trazos zodiacales
1//! Barra-web — taskbar estilo Windows, agnóstica del dominio.
2//!
3//! Maneja la lista dinámica de "tareas" (cajitas, una por ventana abierta)
4//! dentro de un elemento `<ul>` provisto por el host. El layout del resto
5//! de la barra (home button, brand, créditos, dividers, etc.) es
6//! responsabilidad del host — el módulo sólo se encarga del LIST + CLICK.
7//!
8//! Contrato HTML mínimo:
9//! ```html
10//! <ul id="my-tasks" class="taskbar-list" role="presentation"></ul>
11//! ```
12//!
13//! Convenciones de clase generadas:
14//! - `.taskbar-item` — cada cajita
15//! - `.taskbar-item.active` — la cajita visible/foreground
16//! - `.taskbar-item-dot` — punto decorativo dentro de la cajita
17//! - `data-task="<id>"` — identificador único usable por CSS para theming
18//! (`.taskbar-item[data-task="aire"] { --task-color: ... }`)
19//!
20//! El módulo NO inyecta CSS — el host estiliza estas clases.
21//!
22//! ```rust,ignore
23//! let list: HtmlElement = doc.get_element_by_id("my-tasks")?.dyn_into()?;
24//! let bar = barra_web::TaskList::mount(list)?;
25//! bar.set_tasks(&[
26//! Task::new("aire", "AIRE"),
27//! Task::new("fuego", "FUEGO").active(),
28//! ]);
29//! bar.on_click(|id, cx, cy| {
30//! // El click cayó en la cajita `id`. (cx, cy) es el centro de la
31//! // cajita en CSS pixels — útil como origin de animaciones.
32//! });
33//! ```
34
35use std::cell::RefCell;
36use std::rc::Rc;
37
38use wasm_bindgen::prelude::*;
39use wasm_bindgen::JsCast;
40use web_sys::{Element, HtmlElement, MouseEvent};
41
42/// Una tarea (cajita) en la barra.
43#[derive(Clone, Debug)]
44pub struct Task {
45 pub id: String,
46 pub label: String,
47 pub active: bool,
48}
49
50impl Task {
51 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
52 Self {
53 id: id.into(),
54 label: label.into(),
55 active: false,
56 }
57 }
58
59 pub fn active(mut self) -> Self {
60 self.active = true;
61 self
62 }
63}
64
65#[derive(Clone)]
66pub struct TaskList {
67 list: HtmlElement,
68 on_click: Rc<RefCell<Option<Box<dyn FnMut(&str, f64, f64)>>>>,
69}
70
71impl TaskList {
72 /// Monta el módulo sobre el elemento `<ul>` provisto. Instala un único
73 /// listener de click delegado: cualquier click dentro del list que caiga
74 /// sobre un `.taskbar-item` dispara `on_click(id, cx, cy)`.
75 pub fn mount(list: HtmlElement) -> Result<Self, JsValue> {
76 let on_click: Rc<RefCell<Option<Box<dyn FnMut(&str, f64, f64)>>>> =
77 Rc::new(RefCell::new(None));
78 let on_click2 = on_click.clone();
79 let cb = Closure::<dyn FnMut(MouseEvent)>::new(move |e: MouseEvent| {
80 let Some(target) = e.target() else { return };
81 let Ok(target_el): Result<Element, _> = target.dyn_into() else {
82 return;
83 };
84 let Ok(Some(item)) = target_el.closest(".taskbar-item") else {
85 return;
86 };
87 let Some(id) = item.get_attribute("data-task") else {
88 return;
89 };
90 let rect = item.get_bounding_client_rect();
91 let cx = rect.left() + rect.width() / 2.0;
92 let cy = rect.top() + rect.height() / 2.0;
93 if let Some(cb) = on_click2.borrow_mut().as_mut() {
94 cb(&id, cx, cy);
95 }
96 });
97 list.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;
98 cb.forget();
99 Ok(Self { list, on_click })
100 }
101
102 /// Reemplaza el contenido de la lista con las tareas dadas.
103 /// Los IDs se filtran a `[a-zA-Z0-9_-]` para uso seguro en atributos.
104 /// Los labels se HTML-escapan.
105 pub fn set_tasks(&self, tasks: &[Task]) {
106 let mut html = String::new();
107 for t in tasks {
108 let id_safe = sanitize_attr(&t.id);
109 let label_safe = escape_text(&t.label);
110 let active_cls = if t.active { " active" } else { "" };
111 html.push_str(&format!(
112 "<li><button class=\"taskbar-item{active_cls}\" data-task=\"{id_safe}\" type=\"button\">\
113 <span class=\"taskbar-item-dot\" aria-hidden=\"true\"></span>{label_safe}</button></li>"
114 ));
115 }
116 self.list.set_inner_html(&html);
117 }
118
119 /// Registra (o reemplaza) el callback al click sobre una cajita.
120 /// El callback recibe `(id, center_x, center_y)` en CSS pixels.
121 pub fn on_click<F: FnMut(&str, f64, f64) + 'static>(&self, cb: F) {
122 *self.on_click.borrow_mut() = Some(Box::new(cb));
123 }
124
125 /// Centro en CSS pixels de la cajita con `id` dado, o `None` si no existe.
126 pub fn task_center(&self, id: &str) -> Option<(f64, f64)> {
127 let sel = format!(".taskbar-item[data-task=\"{}\"]", sanitize_attr(id));
128 let el = self.list.query_selector(&sel).ok().flatten()?;
129 let rect = el.get_bounding_client_rect();
130 Some((
131 rect.left() + rect.width() / 2.0,
132 rect.top() + rect.height() / 2.0,
133 ))
134 }
135
136 /// Acceso al elemento `<ul>` host por si el caller quiere modificar
137 /// styling o ARIA atributos directamente.
138 pub fn list_el(&self) -> &HtmlElement {
139 &self.list
140 }
141}
142
143fn sanitize_attr(s: &str) -> String {
144 s.chars()
145 .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
146 .collect()
147}
148
149fn escape_text(s: &str) -> String {
150 let mut out = String::with_capacity(s.len());
151 for c in s.chars() {
152 match c {
153 '&' => out.push_str("&"),
154 '<' => out.push_str("<"),
155 '>' => out.push_str(">"),
156 '"' => out.push_str("""),
157 c => out.push(c),
158 }
159 }
160 out
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn task_builder_defaults_inactive() {
169 let t = Task::new("aire", "AIRE");
170 assert!(!t.active);
171 let t2 = Task::new("fuego", "FUEGO").active();
172 assert!(t2.active);
173 }
174
175 #[test]
176 fn sanitize_attr_removes_unsafe_chars() {
177 assert_eq!(sanitize_attr("aire"), "aire");
178 assert_eq!(sanitize_attr("a-b_c"), "a-b_c");
179 assert_eq!(sanitize_attr("ai<re>"), "aire");
180 assert_eq!(sanitize_attr("a\"b"), "ab");
181 }
182
183 #[test]
184 fn escape_text_escapes_html() {
185 assert_eq!(escape_text("AIRE"), "AIRE");
186 assert_eq!(escape_text("<script>"), "<script>");
187 assert_eq!(escape_text("a & b"), "a & b");
188 }
189}