use super::*; /// Sidebar izquierdo unificado (widget `rag-sidebar`): rail de **niveles** (dientes /// coloridos por nivel) + panel con la lista del nivel + CRUD + renombrar + IA + /// historia. Selector **fijo**: el click en un diente cambia de nivel /// (`Activate`→`SelectLevel`, interceptado en el `map`); el cabezal con el nombre del /// nivel lo pone el widget. El resto de mensajes (ejes/buscador/control/resize) van al /// estado del sidebar. pub(crate) fn left_sidebar(model: &Model) -> View { let theme = &model.theme; let btn = ButtonPalette::from_theme(theme); // Un diente por nivel; el icono lleva el color del nivel (ignora el que resuelve // el rail, para conservar la paleta colorida por nivel). let teeth: Vec> = Level::ALL .iter() .map(|&lvl| { RagTooth::new( lvl.index() as u64, lvl.label(), Arc::new(move |_size, _color| icon_view(lvl.icon(), lvl.color(), 1.8)), ) }) .collect(); // El buscador filtra la LISTA de items del nivel por nombre (real, insensible). let filtro = model.sidebar_left.search.trim().to_lowercase(); // Contenido del panel (el cabezal con el nombre del nivel lo pone el widget). let mut rows: Vec> = Vec::new(); for (id, name) in model.items() { if !filtro.is_empty() && !name.to_lowercase().contains(&filtro) { continue; } rows.push(selectable_row(name, id == model.selected(), Msg::SelectItem(id), theme)); } rows.push(spacer(8.0)); // Botonera CRUD en fila. rows.push(crud_row(&btn)); rows.push(spacer(8.0)); rows.push(section_title("NOMBRE", theme)); rows.push(text_input_view_full( &model.name_input, "nombre…", model.name_focused, &TextInputPalette::from_theme(theme), Msg::RenameCampo, )); // IA (según nivel). if matches!(model.level, Level::Biomas | Level::Mundos | Level::Seres | Level::Escenas) { let hint = match model.level { Level::Biomas | Level::Mundos => "p.ej. islas con ríos y nieve", Level::Seres => "p.ej. una niña de remera verde", _ => "p.ej. tres personajes que festejan", }; rows.push(spacer(12.0)); rows.push(section_title("IA — DESCRIBE", theme)); rows.push(text_input_view_full( &model.ai_input, hint, model.ai_focused, &TextInputPalette::from_theme(theme), Msg::AiCampo, )); rows.push(spacer(6.0)); rows.push(button_view( if model.ai_busy { "generando…" } else { "generar (IA)" }, &btn, Msg::AiGenerate, )); } // Guardar/cargar + estado. rows.push(spacer(12.0)); rows.push(button_view("guardar", &btn, Msg::Save)); rows.push(spacer(4.0)); rows.push(button_view("cargar", &btn, Msg::Load)); // Archivos: abrir / guardar-como / combinar una escena de otro archivo. rows.push(spacer(10.0)); rows.push(section_title("ARCHIVO", theme)); rows.push(text_input_view_full( &model.file_input, "ruta.voxstudio.ron", model.file_focused, &TextInputPalette::from_theme(theme), Msg::FileCampo, )); rows.push(spacer(4.0)); rows.push(button_view("abrir", &btn, Msg::Abrir)); rows.push(spacer(4.0)); rows.push(button_view("guardar como", &btn, Msg::GuardarComo)); rows.push(spacer(4.0)); rows.push(button_view("combinar (fundir archivo)", &btn, Msg::Combinar)); // Mundo desde mapa real (GeoJSON del visor + .pgm hermano opcional). rows.push(spacer(4.0)); rows.push(button_view("mundo desde mapa (GeoJSON)", &btn, Msg::MundoDesdeMapa)); if model.mapa.is_some() { rows.push(spacer(4.0)); rows.push(button_view("↩ quitar mapa (volver a procedural)", &btn, Msg::QuitarMapa)); } // Ser desde foto (capa 3): la ruta va en el campo ARCHIVO. La foto regula los rasgos // (piel/ropa) de un personaje estilizado del kit. Sólo en Seres. if model.level == Level::Seres { rows.push(spacer(4.0)); rows.push(button_view("ser desde foto (rasgos)", &btn, Msg::SerDesdeFoto)); } // Historia (DAG): versiones y alternativas — el cimiento del trabajo creativo. rows.push(spacer(10.0)); let hist_info = if model.historia.is_empty() { "VERSIONES — sin versiones".to_string() } else { format!( "VERSIONES — rama {} · {} en total", model.historia.rama_actual(), model.historia.len() ) }; rows.push(section_title(&hist_info, theme)); rows.push(button_view("versionar", &btn, Msg::Versionar)); rows.push(spacer(4.0)); rows.push(button_view("ramificar (alternativa)", &btn, Msg::Ramificar)); // El árbol navegable: una fila por versión (orden de creación), clickeable → // checkout. ● = donde estás parado; ⑂ = tiene alternativas (hermanas). let actual = model.historia.actual(); for (id, ver) in model.historia.versiones_ordenadas() { let marca = if actual == Some(id) { "●" } else { "○" }; let ramif = if model.historia.alternativas(&id).is_empty() { "" } else { " ⑂" }; let etiqueta = format!("{marca} {} {}{ramif}", hex_corto(&id), ver.etiqueta); rows.push(spacer(2.0)); rows.push(button_view(&etiqueta, &btn, Msg::IrAVersion(id))); } // Entrar a la escena: jugarla en primera persona (caminar/volar). rows.push(spacer(10.0)); let jugar = if model.jugando { "◄ salir de la escena" } else { "▶ entrar a la escena (jugar)" }; rows.push(button_view(jugar, &btn, Msg::EntrarSalir)); if model.jugando { let modo = if model.volar { "volando" } else { "caminando" }; rows.push(spacer(2.0)); rows.push(section_title(&format!("JUGANDO · {modo}"), theme)); } rows.push(spacer(10.0)); rows.push( View::new(Style { size: Size { width: percent(1.0_f32), height: Dimension::auto() }, ..Default::default() }) .text(model.status.clone(), 12.0, theme.fg_placeholder) .max_lines(3), ); let body = View::new(Style { flex_direction: FlexDirection::Column, size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, padding: pad(12.0, 12.0), gap: gap_y(6.0), ..Default::default() }) .children(rows); // Source of truth = model.level: el diente del nivel activo queda abierto (robusto // aunque el estado no se haya sincronizado, p.ej. en el pantallazo headless). let mut st = model.sidebar_left.clone(); st.open = Some(model.level.index() as u64); st.selected = Some(model.level.index() as u64); let spec = RagSidebarView { teeth, body, accessory: None, search_hits: !model.sidebar_left.search.is_empty(), t: 0.0, rail_w: RAIL_W, // El click en un diente cambia de nivel (selector fijo: nunca colapsa el // panel); el resto de mensajes van al estado del sidebar. map: Arc::new(|rm| match rm { RagSidebarMsg::Activate(id) => Msg::SelectLevel(Level::from_index(id as usize)), other => Msg::SidebarLeft(other), }), on_drop: None, on_reorder: None, grow: None, }; rag_sidebar_view(&st, spec, RagSide::Left, &RagSidebarPalette::from_theme(theme)) } /// Sidebar derecho unificado (widget `rag-sidebar`): rail de **herramientas** del /// nivel (dientes coloridos, espejados hacia el centro) + el editor del set activo. /// Selector **fijo**: `Activate`→`SelectTool` interceptado; el cabezal con el nombre /// de la herramienta lo pone el widget. pub(crate) fn right_sidebar(model: &Model) -> View { let theme = &model.theme; let tools = model.level.tools(); let tab = model.tool_tab.min(tools.len().saturating_sub(1)); let lvl_color = model.level.color(); let teeth: Vec> = tools .iter() .enumerate() .map(|(i, (label, ic))| { let ic = *ic; RagTooth::new( i as u64, label.to_string(), Arc::new(move |_size, _color| icon_view(ic, lvl_color, 1.8)), ) }) .collect(); let body = View::new(Style { flex_direction: FlexDirection::Column, size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, padding: pad(14.0, 14.0), gap: gap_y(8.0), ..Default::default() }) .children(editor(model, tab)); // Source of truth = model.tool_tab: el diente del tool activo queda abierto. let mut st = model.sidebar_right.clone(); st.open = Some(tab as u64); st.selected = Some(tab as u64); let spec = RagSidebarView { teeth, body, accessory: None, search_hits: !model.sidebar_right.search.is_empty(), t: 0.0, rail_w: RAIL_W, map: Arc::new(|rm| match rm { RagSidebarMsg::Activate(id) => Msg::SelectTool(id as usize), other => Msg::SidebarRight(other), }), on_drop: None, on_reorder: None, grow: None, }; rag_sidebar_view(&st, spec, RagSide::Right, &RagSidebarPalette::from_theme(theme)) } /// Fija el diente abierto de cada sidebar unificado a la fuente de verdad /// (`level`/`tool_tab`): son selectores **fijos**, nunca colapsan. pub(crate) fn pin_sidebars(model: &mut Model) { let lvl = model.level.index() as u64; model.sidebar_left.open = Some(lvl); model.sidebar_left.selected = Some(lvl); let tab = model.tool_tab.min(model.level.tools().len().saturating_sub(1)) as u64; model.sidebar_right.open = Some(tab); model.sidebar_right.selected = Some(tab); } /// Rutea una tecla al buscador de un sidebar unificado enfocado. Esc/Enter sueltan el /// foco; Backspace borra el último char; el `text` del evento se inserta. Traga /// cualquier otra tecla (devuelve `None`) para que los atajos no disparen al tipear. pub(crate) fn sidebar_search_key( ev: &KeyEvent, current: &str, wrap: impl Fn(RagSidebarMsg) -> Msg, ) -> Option { match &ev.key { Key::Named(NamedKey::Escape | NamedKey::Enter) => Some(wrap(RagSidebarMsg::SearchFocus(false))), Key::Named(NamedKey::Backspace) => { let mut s = current.to_string(); s.pop(); Some(wrap(RagSidebarMsg::SearchSet(s))) } _ => { if let Some(t) = &ev.text { if !t.is_empty() && !t.chars().any(char::is_control) { let mut s = current.to_string(); s.push_str(t); return Some(wrap(RagSidebarMsg::SearchSet(s))); } } None } } } /// Backdrop de click-away a pantalla completa: al clickear fuera de un sidebar /// flotante, emite `msg` (típicamente `Reveal(false)`) para guardarlo. pub(crate) fn float_backdrop(msg: Msg) -> View { use llimphi_ui::llimphi_layout::taffy::prelude::Rect; View::new(Style { position: Position::Absolute, inset: Rect { left: length(0.0_f32), right: length(0.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, ..Default::default() }) .on_click(msg) } /// Coloca un sidebar flotante como overlay pegado a un borde vertical /// (`left_side` = izquierda), de alto completo. pub(crate) fn float_overlay(inner: View, left_side: bool) -> View { use llimphi_ui::llimphi_layout::taffy::prelude::Rect; let inset = if left_side { Rect { left: length(0.0_f32), right: auto(), top: length(0.0_f32), bottom: length(0.0_f32) } } else { Rect { left: auto(), right: length(0.0_f32), top: length(0.0_f32), bottom: length(0.0_f32) } }; View::new(Style { position: Position::Absolute, inset, size: Size { width: auto(), height: percent(1.0_f32) }, ..Default::default() }) .children(vec![inner]) } /// El multiselect de disposición (los 4 ejes) del sidebar IZQUIERDO como overlay a /// nivel raíz (no clipeado por el panel) con backdrop de click-away. `None` si el /// control ⚙ está cerrado. pub(crate) fn left_multiselect_overlay(model: &Model) -> Option> { multiselect_overlay(model, true) } /// Ídem para el sidebar DERECHO. pub(crate) fn right_multiselect_overlay(model: &Model) -> Option> { multiselect_overlay(model, false) } /// Arma el overlay del multiselect de un sidebar (izq/der). Anclado al borde interno /// del rail (a la derecha del rail izquierdo, a la izquierda del derecho), cerca del /// control ⚙ del panel; con backdrop que lo cierra. pub(crate) fn multiselect_overlay(model: &Model, left_side: bool) -> Option> { use llimphi_ui::llimphi_layout::taffy::prelude::Rect; let (state, wrap): (&RagSidebarState, fn(RagSidebarMsg) -> Msg) = if left_side { (&model.sidebar_left, Msg::SidebarLeft) } else { (&model.sidebar_right, Msg::SidebarRight) }; if !state.control_open { return None; } let card = rag_multiselect_view( state, RagOptions::default(), Arc::new(wrap), &RagSidebarPalette::from_theme(&model.theme), ); let inset = if left_side { Rect { left: length(RAIL_W + 8.0), right: auto(), top: length(36.0_f32), bottom: auto() } } else { Rect { left: auto(), right: length(RAIL_W + 8.0), top: length(36.0_f32), bottom: auto() } }; let card_pos = View::new(Style { position: Position::Absolute, inset, ..Default::default() }) .children(vec![card]); let backdrop = View::new(Style { position: Position::Absolute, inset: Rect { left: length(0.0_f32), right: length(0.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, ..Default::default() }) .on_click(wrap(RagSidebarMsg::ControlToggle)); Some( View::new(Style { position: Position::Relative, size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, ..Default::default() }) .children(vec![backdrop, card_pos]), ) } /// Fila de botones CRUD: nuevo · duplicar · borrar. pub(crate) fn crud_row(btn: &ButtonPalette) -> View { View::new(Style { flex_direction: FlexDirection::Row, size: Size { width: percent(1.0_f32), height: Dimension::auto() }, gap: Size { width: length(4.0_f32), height: length(0.0_f32) }, ..Default::default() }) .children(vec![ cell(button_view("+ nuevo", btn, Msg::NewItem)), cell(button_view("duplicar", btn, Msg::DupItem)), cell(button_view("borrar", btn, Msg::DelItem)), ]) } /// Celda que crece para repartir el ancho de una fila. pub(crate) fn cell(child: View) -> View { View::new(Style { flex_grow: 1.0, size: Size { width: percent(0.0_f32), height: Dimension::auto() }, ..Default::default() }) .children(vec![child]) } // ============================================================================= // Centro (preview) // ============================================================================= pub(crate) fn center(model: &Model) -> View { let inner = match model.level { Level::Leyes if model.leyes_node_mode => eq_graph_view(model), Level::Leyes => law_lab_view(model), Level::Materiales => material_swatch(model), _ => canvas_3d(model), }; View::new(Style { flex_grow: 1.0, size: Size { width: Dimension::auto(), height: percent(1.0_f32) }, ..Default::default() }) .fill(Color::from_rgba8(12, 14, 18, 255)) .children(vec![inner]) } /// Mensaje centrado para niveles sin preview 3D. pub(crate) fn placeholder_2d(text: &str, theme: &Theme) -> View { View::new(Style { position: Position::Absolute, size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, align_items: Some(AlignItems::CENTER), justify_content: Some(llimphi_ui::llimphi_layout::taffy::prelude::JustifyContent::CENTER), padding: pad(40.0, 40.0), ..Default::default() }) .children(vec![View::new(Style { size: Size { width: percent(0.7_f32), height: Dimension::auto() }, ..Default::default() }) .text(text.to_string(), 16.0, theme.fg_muted) .max_lines(4)]) } /// Centro del nivel Leyes: el **laboratorio** — heatmap del campo visible de la ley /// corriendo la ecuación, o el error de parseo, o una guía si no hay ley por ecuación. pub(crate) fn law_lab_view(model: &Model) -> View { let theme = &model.theme; let Some(lab) = &model.lab else { return placeholder_2d( "Elige una ley por ecuación (botón «preset» a la derecha) y edita su fórmula. ▶ para simular.", theme, ); }; if let Err(err) = &lab.program { return placeholder_2d(&format!("La fórmula no compila:\n{err}"), theme); } let field_name = lab.engine.fields().get(lab.vis).map(|f| f.name.clone()).unwrap_or_default(); let estado = if model.simulating { "▶ corriendo" } else { "▮ en pausa" }; let overlay = View::new(Style { position: Position::Absolute, size: Size { width: percent(1.0_f32), height: Dimension::auto() }, padding: pad(12.0, 10.0), ..Default::default() }) .text(format!("campo «{field_name}» · {LAB_DIM}² · {estado}"), 13.0, theme.fg_muted); View::new(Style { position: Position::Absolute, size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, align_items: Some(AlignItems::CENTER), justify_content: Some(llimphi_ui::llimphi_layout::taffy::prelude::JustifyContent::CENTER), padding: pad(24.0, 24.0), ..Default::default() }) .children(vec![ View::new(Style { size: Size { width: percent(0.82_f32), height: percent(0.82_f32) }, ..Default::default() }) .image(heatmap_image(lab)), overlay, ]) } /// Centro del nivel Leyes en **modo grafo**: la ecuación como nodos (misma AST que la /// fórmula). Arrastrar la barra de título reubica; arrastrar de un pin de salida a uno /// de entrada reconecta (y reescribe la fórmula); right‑click borra el nodo. pub(crate) fn eq_graph_view(model: &Model) -> View { let theme = &model.theme; let (Some(g), Some(sym)) = (&model.eq_graph, sel_ley(model).and_then(|l| l.kind.ecuacion_symbols())) else { return placeholder_2d("Grafo no disponible (la ley no es por ecuación).", theme); }; let specs = g.node_specs(&sym); let palette = NodegraphPalette::from_theme(theme); let metrics = NodegraphMetrics::default(); let inner = nodegraph_view_ex( &specs, &g.wires, &palette, &metrics, |id, phase, dx, dy| match phase { DragPhase::Move => Some(Msg::GraphDrag(id, dx, dy)), _ => None, }, |from, fp, to, tp| Some(Msg::GraphConnect(from, fp, to, tp)), Some(|id| Some(Msg::GraphDeleteNode(id))), ); View::new(Style { position: Position::Absolute, size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, ..Default::default() }) .children(vec![inner]) } /// Heatmap RGBA del campo visible del laboratorio (rango del campo → rampa magma). pub(crate) fn heatmap_image(lab: &LawLab) -> Image { let f = lab.vis as u16; let (mn, mx) = lab.engine.fields().get(lab.vis).map(|d| (d.min, d.max)).unwrap_or((0.0, 1.0)); let span = (mx - mn).max(1e-6); let mut rgba = vec![0u8; (LAB_DIM * LAB_DIM * 4) as usize]; for z in 0..LAB_DIM { for x in 0..LAB_DIM { let t = ((lab.engine.get(f, x, 0, z) - mn) / span).clamp(0.0, 1.0); let [r, g, b] = ramp(t); let i = ((z * LAB_DIM + x) * 4) as usize; rgba[i] = r; rgba[i + 1] = g; rgba[i + 2] = b; rgba[i + 3] = 255; } } from_rgba8(rgba, LAB_DIM, LAB_DIM) } /// Rampa de color tipo *magma* (negro → púrpura → naranja → crema) para `t∈[0,1]`. pub(crate) fn ramp(t: f32) -> [u8; 3] { pub(crate) const STOPS: [(f32, [f32; 3]); 4] = [ (0.0, [8.0, 6.0, 30.0]), (0.4, [92.0, 22.0, 110.0]), (0.72, [222.0, 92.0, 58.0]), (1.0, [250.0, 232.0, 158.0]), ]; let t = t.clamp(0.0, 1.0); let mut out = STOPS[STOPS.len() - 1].1; for w in STOPS.windows(2) { let (t0, c0) = w[0]; let (t1, c1) = w[1]; if t <= t1 { let k = ((t - t0) / (t1 - t0).max(1e-6)).clamp(0.0, 1.0); out = [ c0[0] + (c1[0] - c0[0]) * k, c0[1] + (c1[1] - c0[1]) * k, c0[2] + (c1[2] - c0[2]) * k, ]; break; } } [out[0] as u8, out[1] as u8, out[2] as u8] } /// Lado del blob de material del swatch 3D. pub(crate) const BLOB_DIM: u32 = 44; /// Un **blob** (esfera) voxel del color dado, para mostrar el material en 3D. La /// superficie curva exhibe bien el bisel (suavizado) y la rugosidad (bump). pub(crate) fn material_blob(color: [u8; 3]) -> VoxelGrid { let d = BLOB_DIM; let mut g = VoxelGrid::new([d, d, d]); let c = (d as f32 - 1.0) / 2.0; let r = d as f32 * 0.42; for z in 0..d { for y in 0..d { for x in 0..d { let dx = x as f32 - c; let dy = y as f32 - c; let dz = z as f32 - c; if dx * dx + dy * dy + dz * dz <= r * r { g.set(x, y, z, color); } } } } g } /// **Preview 3D vivo** del material seleccionado: un blob voxel con su color + su /// detalle sub-voxel (rugosidad/bisel), para dialar los sliders y verlos al instante. /// Reusa el `preview` compartido vía `show_grid` (clave por material+color; el detalle /// se aplica por cuadro sin reconstruir). Arrastrar = orbitar. pub(crate) fn material_swatch(model: &Model) -> View { let mid = model.sel[1]; let color = model.project.resolve_material(mid).color; let (rug, bis) = model.project.detalle_material(mid); let (yaw, pitch) = (model.yaw, model.pitch); let preview = model.preview.clone(); let mr = model.preview_render(); // Clave de rebuild: material + color, con bit alto para no chocar con los tags de // interior/terreno. Cambia sólo al cambiar de material o su color → rugosidad/bisel // se aplican sin reconstruir el grid. let key = 0xF000_0000_0000_0000u64 | ((mid & 0xffff) << 24) | ((color[0] as u64) << 16) | ((color[1] as u64) << 8) | color[2] as u64; let absolute = Style { position: Position::Absolute, size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, ..Default::default() }; View::new(absolute) .gpu_paint_with(move |device, queue, encoder, target, rect, vp| { let dim = [BLOB_DIM, BLOB_DIM, BLOB_DIM]; let mut guard = preview.lock().unwrap(); let p = guard.get_or_insert_with(|| WorldPreview::build(device, queue, &mr, dim, 0, None)); p.show_grid(device, queue, material_blob(color), dim, key); // Luz frontal limpia (sin niebla) para leer el relieve; luego el detalle. p.set_lighting( [0.4, 0.6, 0.7], Atmosphere { sky_zenith: [40, 46, 62], sky_horizon: [82, 86, 100], fog_density: 0.0, god_rays: 0.0 }, ); p.set_detalle(rug, 1.6, bis); let camera = Camera3d::orbit(Vec3::ZERO, yaw, pitch, BLOB_DIM as f32 * 1.9); p.render(device, queue, encoder, target, vp, (rect.x, rect.y, rect.w, rect.h), &camera); }) .draggable(|phase, dx, dy| match phase { DragPhase::Move => Some(Msg::Orbit(dx, dy)), DragPhase::End => None, }) }