tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definitions

⎇ 7728013012444c20c7fe7c4248c0bc7c296f43f7 crates/modules/barra/barra-web/src/lib.rs Rust · 189 lines · 6.3 KB History Who touched what raw

feat(gioser/web): fix mobile swipe, taskbar agnóstica, trazos zodiacales 772801301 · sergio ·

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("&amp;"),
154            '<' => out.push_str("&lt;"),
155            '>' => out.push_str("&gt;"),
156            '"' => out.push_str("&quot;"),
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>"), "&lt;script&gt;");
187        assert_eq!(escape_text("a & b"), "a &amp; b");
188    }
189}