diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e629119..7b1c274 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,3 +82,22 @@ jobs: components: rustfmt - name: Run cargo fmt run: cargo fmt --all -- --check + + # Run wasm compile checks to catch web-only type mismatches. + wasm_check: + name: Wasm Compile + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout sources + uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install stable toolchain + uses: dtolnay/rust-toolchain@stable + - name: Check wasm target + run: rustup target add wasm32-unknown-unknown + - name: Run wasm compile checks + run: | + cargo check -p vizmat-core --target wasm32-unknown-unknown --no-default-features + cargo check -p vizmat-app --target wasm32-unknown-unknown --no-default-features diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b859071..ba5529b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,6 +30,11 @@ jobs: run: | curl https://drager.github.io/wasm-pack/installer/init.sh -sSf | bash + - name: Run wasm compile checks + run: | + cargo check -p vizmat-core --target wasm32-unknown-unknown --no-default-features + cargo check -p vizmat-app --target wasm32-unknown-unknown --no-default-features + - name: Build WASM run: | if [ "${{ github.ref }}" = "refs/heads/main" ]; then diff --git a/justfile b/justfile index c9fcef9..3d3fcc2 100644 --- a/justfile +++ b/justfile @@ -61,6 +61,11 @@ watch: bench: cargo bench -p vizmat-core --bench bond_cache +wasm-check: + rustup target add wasm32-unknown-unknown + cargo check -p vizmat-core --target wasm32-unknown-unknown --no-default-features + cargo check -p vizmat-app --target wasm32-unknown-unknown --no-default-features + wasm: rustup target add wasm32-unknown-unknown --toolchain nightly-aarch64-apple-darwin cd vizmat-app && PATH="$HOME/.cargo/bin:$PATH" NO_COLOR=false trunk serve --port 8082 diff --git a/vizmat-app/index.html b/vizmat-app/index.html index 8ff2688..0342c71 100644 --- a/vizmat-app/index.html +++ b/vizmat-app/index.html @@ -50,5 +50,16 @@

vizmat molecule / crystal visual lab

+ diff --git a/vizmat-app/index.js b/vizmat-app/index.js index db47cf9..4155757 100644 --- a/vizmat-app/index.js +++ b/vizmat-app/index.js @@ -2,8 +2,355 @@ const loader = document.getElementById("app-loader"); const status = document.getElementById("loader-status"); const canvas = document.getElementById("bevy-canvas"); + const pickerInput = document.getElementById("picker-keyboard-input"); if (!loader) return; + const viewportState = { + width: window.innerWidth, + height: window.innerHeight, + }; + let pickerOpen = false; + + const applyViewportSizing = () => { + const width = window.innerWidth; + const height = window.innerHeight; + const shouldUpdate = + !pickerOpen || height >= viewportState.height || width !== viewportState.width; + + if (shouldUpdate) { + viewportState.width = width; + viewportState.height = height; + + const root = document.documentElement; + const desktopCanvasHeight = Math.min(height * 0.8, 900); + const mobileCanvasHeight = Math.min(height * 0.82, 900); + + root.style.setProperty("--app-viewport-height", `${height}px`); + root.style.setProperty( + "--app-canvas-height-desktop", + `${desktopCanvasHeight}px`, + ); + root.style.setProperty( + "--app-canvas-height-mobile", + `${mobileCanvasHeight}px`, + ); + } + }; + + window.addEventListener("resize", () => { + window.requestAnimationFrame(applyViewportSizing); + }); + applyViewportSizing(); + + const emitPickerQuery = (action) => { + if (!pickerInput) return; + const query = pickerInput.value || ""; + window.dispatchEvent( + new CustomEvent("vizmat-picker-query", { + detail: { + query, + action, + }, + }), + ); + }; + + if (pickerInput) { + window.addEventListener("vizmat-structure-picker-open", () => { + pickerOpen = true; + pickerInput.value = ""; + try { + pickerInput.focus({ preventScroll: true }); + } catch (error) { + pickerInput.focus(); + window.scrollTo(0, 0); + } + emitPickerQuery("change"); + }); + + window.addEventListener("vizmat-structure-picker-close", () => { + pickerOpen = false; + pickerInput.blur(); + pickerInput.value = ""; + applyViewportSizing(); + }); + + pickerInput.addEventListener("input", () => emitPickerQuery("change")); + pickerInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + emitPickerQuery("submit"); + event.stopPropagation(); + } + }); + } + + const emitTouchGesture = (gesture) => { + window.dispatchEvent( + new CustomEvent("vizmat-touch-gesture", { + detail: gesture, + }), + ); + }; + + const toVec = (touch) => ({ x: touch.clientX, y: touch.clientY }); + + const touchPanState = { + one: null, + two: { + active: false, + midpoint: null, + distance: null, + }, + }; + + const getMidpoint = (a, b) => ({ + x: (a.x + b.x) * 0.5, + y: (a.y + b.y) * 0.5, + }); + + const getDistance = (a, b) => { + const dx = a.x - b.x; + const dy = a.y - b.y; + return Math.hypot(dx, dy); + }; + + const clearTouchState = () => { + touchPanState.one = null; + touchPanState.two = { + active: false, + midpoint: null, + distance: null, + }; + }; + + const emitIf = (condition, payload) => { + if (!condition) return false; + emitTouchGesture(payload); + return true; + }; + + if (canvas) { + canvas.style.touchAction = "none"; + + if (window.PointerEvent) { + const pointerState = new Map(); + + const removePointer = (event) => { + if (event.pointerType !== "touch") return; + if (!event.currentTarget || !pointerState.has(event.pointerId)) return; + pointerState.delete(event.pointerId); + + if (pointerState.size === 0) { + clearTouchState(); + return; + } + + if (pointerState.size === 1) { + touchPanState.one = [...pointerState.values()][0]; + touchPanState.two = { + active: false, + midpoint: null, + distance: null, + }; + return; + } + + const points = [...pointerState.values()]; + touchPanState.two = { + active: true, + midpoint: getMidpoint(points[0], points[1]), + distance: getDistance(points[0], points[1]), + }; + }; + + const updateSinglePointer = (point) => { + if (!touchPanState.one) { + touchPanState.one = point; + return; + } + + const dx = point.x - touchPanState.one.x; + const dy = point.y - touchPanState.one.y; + const emitted = emitIf(dx !== 0 || dy !== 0, { + gesture: "Rotate", + dx, + dy, + scale_delta: 0, + }); + if (emitted) { + touchPanState.one = point; + } + }; + + const updateTwoPointer = (a, b) => { + if (!touchPanState.two.active) { + touchPanState.two = { + active: true, + midpoint: getMidpoint(a, b), + distance: getDistance(a, b), + }; + return; + } + + const midpoint = getMidpoint(a, b); + const distance = getDistance(a, b); + const panDx = midpoint.x - touchPanState.two.midpoint.x; + const panDy = midpoint.y - touchPanState.two.midpoint.y; + const scaleDelta = + touchPanState.two.distance > 0 + ? (distance - touchPanState.two.distance) / touchPanState.two.distance + : 0; + + const emitted = emitIf(panDx !== 0 || panDy !== 0 || scaleDelta !== 0, { + gesture: "TwoFinger", + dx: panDx, + dy: panDy, + scale_delta: scaleDelta, + }); + + if (emitted) { + touchPanState.two.midpoint = midpoint; + touchPanState.two.distance = distance; + } + }; + + canvas.addEventListener( + "pointerdown", + (event) => { + if (event.pointerType !== "touch") return; + if (!event.target) return; + const point = toVec(event); + pointerState.set(event.pointerId, point); + event.currentTarget.setPointerCapture(event.pointerId); + touchPanState.one = point; + touchPanState.two = { + active: false, + midpoint: null, + distance: null, + }; + event.preventDefault(); + }, + { passive: false }, + ); + + canvas.addEventListener( + "pointermove", + (event) => { + if (event.pointerType !== "touch") return; + if (!pointerState.has(event.pointerId)) return; + + const point = toVec(event); + pointerState.set(event.pointerId, point); + + if (pointerState.size === 1) { + updateSinglePointer(point); + } else if (pointerState.size >= 2) { + const points = [...pointerState.values()]; + const a = points[0]; + const b = points[1]; + updateTwoPointer(a, b); + } + + event.preventDefault(); + }, + { passive: false }, + ); + + canvas.addEventListener( + "pointerup", + removePointer, + { passive: false }, + ); + canvas.addEventListener("pointercancel", removePointer, { + passive: false, + }); + canvas.addEventListener("pointerleave", removePointer, { + passive: false, + }); + canvas.addEventListener("pointerout", removePointer, { passive: false }); + canvas.addEventListener("pointerlostcapture", removePointer); + } else { + canvas.addEventListener( + "touchstart", + (event) => { + if (!event.target) return; + if (event.touches.length === 1) { + touchPanState.one = toVec(event.touches[0]); + touchPanState.two.active = false; + } else if (event.touches.length === 2) { + const a = toVec(event.touches[0]); + const b = toVec(event.touches[1]); + touchPanState.two = { + active: true, + midpoint: getMidpoint(a, b), + distance: getDistance(a, b), + }; + touchPanState.one = null; + } + event.preventDefault(); + }, + { passive: false }, + ); + + canvas.addEventListener( + "touchmove", + (event) => { + if (event.touches.length === 1 && touchPanState.one) { + const current = toVec(event.touches[0]); + const dx = current.x - touchPanState.one.x; + const dy = current.y - touchPanState.one.y; + + if (dx !== 0 || dy !== 0) { + emitTouchGesture({ + gesture: "Rotate", + dx, + dy, + scale_delta: 0, + }); + touchPanState.one = current; + } + event.preventDefault(); + return; + } + + if (event.touches.length === 2 && touchPanState.two.active) { + const a = toVec(event.touches[0]); + const b = toVec(event.touches[1]); + const midpoint = getMidpoint(a, b); + const distance = getDistance(a, b); + + const panDx = midpoint.x - touchPanState.two.midpoint.x; + const panDy = midpoint.y - touchPanState.two.midpoint.y; + const scaleDelta = + touchPanState.two.distance > 0 + ? (distance - touchPanState.two.distance) / touchPanState.two.distance + : 0; + + if (panDx !== 0 || panDy !== 0 || scaleDelta !== 0) { + emitTouchGesture({ + gesture: "TwoFinger", + dx: panDx, + dy: panDy, + scale_delta: scaleDelta, + }); + touchPanState.two.midpoint = midpoint; + touchPanState.two.distance = distance; + } + + event.preventDefault(); + } + }, + { passive: false }, + ); + } + + const clearTouch = () => clearTouchState(); + canvas.addEventListener("touchend", clearTouch, { passive: false }); + canvas.addEventListener("touchcancel", clearTouch, { passive: false }); + canvas.addEventListener("touchleave", clearTouch, { passive: false }); + } + if (canvas) { // Keep RMB free for in-app panning instead of opening browser context menu. canvas.addEventListener("contextmenu", (event) => event.preventDefault()); @@ -16,6 +363,44 @@ window.setTimeout(() => loader.remove(), 260); }; + const setStatus = (message) => { + if (status) { + status.textContent = message; + } + }; + + const startApp = () => { + const bindings = window.wasmBindings; + if (!bindings || typeof bindings.start !== "function") { + setStatus("Waiting for WebAssembly bindings..."); + return false; + } + + try { + bindings.start(); + hideLoader(); + return true; + } catch (error) { + console.error("Failed to start WASM app:", error); + setStatus("Failed to start WebAssembly app."); + return false; + } + }; + + // Trunk injects the WASM module and then fires this event. + window.addEventListener( + "TrunkApplicationStarted", + () => { + startApp(); + }, + { once: true }, + ); + + // If Trunk fired before this script loaded, start immediately. + if (window.wasmBindings?.start) { + startApp(); + } + // Trunk injects a startup script and emits this event after WASM init. // If the event fired before this listener was added, hide immediately. if (window.wasmBindings) { diff --git a/vizmat-app/style.css b/vizmat-app/style.css index 584d4b3..35b9134 100644 --- a/vizmat-app/style.css +++ b/vizmat-app/style.css @@ -20,6 +20,9 @@ #01040a 100% ); --canvas-bg: #030812; + --app-viewport-height: 100vh; + --app-canvas-height-desktop: min(80vh, 900px); + --app-canvas-height-mobile: min(82vh, 900px); } html[data-theme="light"] { --bg: #f7f8fb; @@ -46,7 +49,7 @@ html[data-theme="light"] { } html, body { width: 100%; - height: 100%; + height: var(--app-viewport-height); touch-action: none; overscroll-behavior: none; user-select: none; @@ -143,7 +146,7 @@ canvas { top: 50%; transform: translate(-50%, -50%); width: min(80vw, 1400px); - height: min(80vh, 900px); + height: var(--app-canvas-height-desktop); border-radius: 14px; overflow: hidden; border: 1px solid var(--line); @@ -154,6 +157,23 @@ canvas { user-select: none; -webkit-user-select: none; } +#picker-keyboard-input { + position: fixed; + left: 0; + top: 0; + width: 1px; + height: 1px; + opacity: 0; + padding: 0; + margin: 0; + border: 0; + outline: 0; + background: transparent; + color: transparent; + caret-color: transparent; + z-index: 9999; + pointer-events: none; +} .loader { position: fixed; inset: 0; @@ -218,13 +238,21 @@ canvas { } } @media (max-width: 720px) { + html, + body { + position: fixed; + inset: 0; + overflow: hidden; + } .site-title { max-width: 66vw; line-height: 1.25; } .canvas-shell { width: 94vw; - height: 82vh; + height: var(--app-canvas-height-mobile); + top: 28px; + transform: translateX(-50%); border-radius: 10px; } header { @@ -239,3 +267,7 @@ canvas { font-size: 0.66rem; } } + +body.picker-keyboard-open { + overflow: hidden; +} diff --git a/vizmat-core/src/lib.rs b/vizmat-core/src/lib.rs index 941ceae..44059b2 100644 --- a/vizmat-core/src/lib.rs +++ b/vizmat-core/src/lib.rs @@ -32,18 +32,22 @@ use crate::structure::{ Crystal, UpdateStructure, }; use crate::ui::{ - apply_bond_tolerance_debounce, apply_theme_to_atom_hover_panel, apply_theme_to_hud, - apply_theme_to_startup_screen, auto_reset_view_on_crystal_change, bond_tolerance_controls, - camera_controls, cleanup_startup_screen, color_mode_button, handle_catalog_load_results, - handle_load_default_button, handle_open_file_button, hide_non_startup_controls, - refresh_structure_picker_panel, reset_camera_button_interaction, setup_cameras, setup_file_ui, - setup_light, setup_startup_screen, show_non_startup_controls, structure_picker_keyboard_search, - structure_picker_result_buttons, structure_picker_toggle_button, sync_atom_selection_highlight, - sync_color_mode_label, sync_gizmo_axis_rotation, toggle_light_attachment, toggle_theme_button, - transition_to_running_on_structure_loaded, update_atom_hover_cache, update_atom_hover_label, - update_bond_order_legend, update_color_mode_availability, update_file_ui, - update_gizmo_viewport, update_scene, update_selected_atom_from_click, - update_structure_loading_overlay, AppUiState, CatalogLoadChannel, TouchGestureState, + apply_bond_tolerance_debounce, apply_structure_picker_query_text, + apply_theme_to_atom_hover_panel, apply_theme_to_hud, apply_theme_to_startup_screen, + auto_reset_view_on_crystal_change, blink_structure_picker_query_caret, bond_tolerance_controls, + camera_controls, cleanup_startup_screen, color_mode_button, filtered_structure_entries, + handle_catalog_load_results, handle_load_default_button, handle_open_file_button, + hide_non_startup_controls, refresh_structure_picker_panel, reset_camera_button_interaction, + set_structure_picker_keyboard_active, setup_cameras, setup_file_ui, setup_light, + setup_startup_screen, show_non_startup_controls, structure_picker_keyboard_search, + structure_picker_result_buttons, structure_picker_scroll, structure_picker_toggle_button, + sync_atom_selection_highlight, sync_color_mode_label, sync_gizmo_axis_rotation, + toggle_light_attachment, toggle_theme_button, transition_to_running_on_structure_loaded, + update_atom_hover_cache, update_atom_hover_label, update_bond_order_legend, + update_color_mode_availability, update_file_ui, update_gizmo_viewport, update_scene, + update_selected_atom_from_click, update_structure_loading_overlay, + update_structure_picker_scroll_indicator, AppUiState, CatalogLoadChannel, + StructurePickerCaretState, StructurePickerState, TouchGestureState, }; use crate::ui::{setup_buttons, spawn_axis}; @@ -89,6 +93,10 @@ pub enum WebEvent { dy: f32, scale_delta: f32, }, + StructurePickerQuery { + query: String, + submit: bool, + }, } #[derive(Clone, Copy, Debug)] @@ -133,6 +141,13 @@ struct TouchGesturePayload { scale_delta: f32, } +#[allow(dead_code)] +#[derive(serde::Deserialize)] +struct StructurePickerQueryPayload { + query: String, + action: String, +} + pub struct WebPlugin { #[cfg_attr(not(target_family = "wasm"), allow(dead_code))] pub dom_drop_element_id: String, @@ -174,6 +189,7 @@ impl Plugin for WebPlugin { set_sender(sender); register_drop(&self.dom_drop_element_id).unwrap(); register_touch_gesture_listener().unwrap(); + register_structure_picker_query_listener().unwrap(); } } } @@ -378,6 +394,44 @@ fn register_touch_gesture_listener() -> Option<()> { Some(()) } +#[cfg(target_arch = "wasm32")] +fn register_structure_picker_query_listener() -> Option<()> { + let document = gloo::utils::document(); + let window = document.default_view()?; + + EventListener::new_with_options( + &window, + "vizmat-picker-query", + EventListenerOptions::enable_prevent_default(), + move |event| { + let event: CustomEvent = match event.clone().dyn_into() { + Ok(event) => event, + Err(err) => { + warn!("Ignoring invalid structure-picker-query event: {err:?}"); + return; + } + }; + + let payload: StructurePickerQueryPayload = match event.detail().into_serde() { + Ok(payload) => payload, + Err(err) => { + warn!("Ignoring structure-picker-query payload parse failure: {err}"); + return; + } + }; + + let submit = payload.action.eq_ignore_ascii_case("submit"); + send_event(WebEvent::StructurePickerQuery { + query: payload.query, + submit, + }); + }, + ) + .forget(); + + Some(()) +} + /// Shared function for Bevy app setup pub fn run_app() { App::new() @@ -457,10 +511,13 @@ pub fn run_app() { ) .add_systems(Update, structure_picker_toggle_button) .add_systems(Update, structure_picker_keyboard_search) + .add_systems(Update, blink_structure_picker_query_caret) .add_systems( Update, refresh_structure_picker_panel.after(structure_picker_keyboard_search), ) + .add_systems(Update, update_structure_picker_scroll_indicator) + .add_systems(Update, structure_picker_scroll) .add_systems(Update, structure_picker_result_buttons) .add_systems( Update, @@ -529,12 +586,16 @@ pub fn run_app() { .run(); } +#[allow(clippy::too_many_arguments)] fn web_event_observer( trigger: Trigger, mut file_drag_drop: ResMut, mut next_ui_state: ResMut>, mut commands: Commands, mut touch_gesture_state: ResMut, + mut picker: ResMut, + mut picker_caret_state: ResMut, + catalog_channel: Option>, ) { match trigger.event() { WebEvent::Drop { @@ -591,6 +652,28 @@ fn web_event_observer( touch_gesture_state.zoom += *scale_delta; } }, + WebEvent::StructurePickerQuery { query, submit } => { + apply_structure_picker_query_text(&mut picker, &mut picker_caret_state, query.clone()); + + if !picker.visible { + picker.visible = true; + set_structure_picker_keyboard_active(true); + } + + if !submit { + return; + } + + if let Some(first) = filtered_structure_entries(&picker).first().cloned() { + crate::ui::load_structure_from_catalog_path( + &first, + &mut file_drag_drop, + catalog_channel.as_deref(), + ); + picker.visible = false; + set_structure_picker_keyboard_active(false); + } + } } } diff --git a/vizmat-core/src/ui.rs b/vizmat-core/src/ui.rs index 3bcd2b0..8ed62be 100644 --- a/vizmat-core/src/ui.rs +++ b/vizmat-core/src/ui.rs @@ -4,9 +4,8 @@ use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; use std::path::PathBuf; use bevy::ecs::system::SystemParam; -use bevy::input::keyboard::{Key, KeyboardInput}; use bevy::input::mouse::{MouseMotion, MouseWheel}; -use bevy::input::ButtonState; +use bevy::picking::hover::HoverMap; use bevy::prelude::*; use bevy::render::camera::Viewport; use bevy::render::view::RenderLayers; @@ -33,6 +32,17 @@ use crate::structure::{ BondOrder, Crystal, }; +mod picker; +pub(crate) use picker::{ + apply_structure_picker_query_text, blink_structure_picker_query_caret, + filtered_structure_entries, parse_embedded_structure_entries, refresh_structure_picker_panel, + set_structure_picker_keyboard_active, setup_structure_picker_panel, + structure_picker_keyboard_search, structure_picker_result_buttons, structure_picker_scroll, + structure_picker_toggle_button, update_structure_picker_scroll_indicator, + StructurePickerCaretState, StructurePickerResultsScroll, StructurePickerSelectionState, + StructurePickerState, StructurePickerToggleButton, +}; + const LAYER_GIZMO: RenderLayers = RenderLayers::layer(1); const LAYER_CANVAS: RenderLayers = RenderLayers::layer(0); const GIZMO_VIEWPORT_SIZE_PX: u32 = 200; @@ -97,6 +107,37 @@ pub(crate) struct GizmoCamera; #[derive(Component)] pub(crate) struct FileUploadText; +#[derive(Component)] +pub(crate) struct FileUploadPopupRoot; + +#[derive(Component)] +pub(crate) struct FileUploadPopupDismissButton; + +#[derive(Component)] +pub(crate) struct FileUploadPopupBackdrop; + +#[derive(Component)] +pub(crate) struct FileUploadPopupPanel; + +#[derive(Resource)] +pub(crate) struct FileUploadPopupState { + hide_after: Option, + dismissed: bool, + last_status_message: String, + last_status_kind: FileStatusKind, +} + +impl Default for FileUploadPopupState { + fn default() -> Self { + Self { + hide_after: None, + dismissed: true, + last_status_message: String::new(), + last_status_kind: FileStatusKind::Info, + } + } +} + // Component for load default button #[derive(Component)] pub(crate) struct LoadDefaultButton; @@ -111,40 +152,28 @@ pub(crate) struct ThemeToggleButton; pub(crate) struct ThemeToggleIcon; #[derive(Component)] -pub(crate) struct StructurePickerToggleButton; +pub(crate) struct HudTopBar; #[derive(Component)] -pub(crate) struct StructurePickerPanel; +pub(crate) struct HudBottomBar; #[derive(Component)] -pub(crate) struct StructurePickerQueryText; +pub(crate) struct HudButton; #[derive(Component)] -pub(crate) struct StructurePickerResultsRoot; - -#[derive(Component, Clone)] -pub(crate) struct StructurePickerResultButton { - pub(crate) path: String, -} - -#[derive(Resource, Default)] -pub(crate) struct StructurePickerState { - pub(crate) entries: Vec, - pub(crate) query: String, - pub(crate) visible: bool, -} +pub(crate) struct HudButtonLabel; #[derive(Component)] -pub(crate) struct HudTopBar; +pub(crate) struct HudButtonIcon; #[derive(Component)] -pub(crate) struct HudBottomBar; +pub(crate) struct LoadStructureLabel; #[derive(Component)] -pub(crate) struct HudButton; +pub(crate) struct ResetCameraLabel; #[derive(Component)] -pub(crate) struct HudButtonLabel; +pub(crate) struct LightAttachmentLabel; #[derive(Component)] pub(crate) struct HudHelpText; @@ -754,6 +783,7 @@ type HudBgQueries<'w, 's> = ( type HudTextQueries<'w, 's> = ( Query<'w, 's, &'static mut TextColor, With>, + Query<'w, 's, &'static mut TextColor, With>, Query<'w, 's, &'static mut TextColor, (With, Without)>, Query<'w, 's, &'static mut TextColor, With>, Query<'w, 's, &'static mut TextColor, With>, @@ -815,33 +845,6 @@ type StructureLoadingNodeQueries<'w, 's> = ( Query<'w, 's, &'static mut Node, With>, ); -fn parse_embedded_structure_entries() -> Vec { - EMBEDDED_STRUCTURE_LIST - .lines() - .map(str::trim) - .filter(|line| !line.is_empty() && !line.starts_with('#')) - .map(ToOwned::to_owned) - .collect() -} - -fn structure_matches_query(path: &str, query: &str) -> bool { - if query.trim().is_empty() { - return true; - } - path.to_ascii_lowercase() - .contains(&query.trim().to_ascii_lowercase()) -} - -fn filtered_structure_entries(state: &StructurePickerState) -> Vec { - state - .entries - .iter() - .filter(|entry| structure_matches_query(entry, &state.query)) - .take(12) - .cloned() - .collect() -} - #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn structures_local_dir() -> &'static str { option_env!("VIZMAT_STRUCTURES_LOCAL_DIR").unwrap_or(concat!( @@ -1186,15 +1189,32 @@ pub(crate) fn setup_file_ui(mut commands: Commands, mut font_assets: ResMut String { } } +#[allow(clippy::too_many_arguments)] pub(crate) fn update_file_ui( file_drag_drop: Res, theme: Res, + time: Res