Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/system/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<img width="320" src="https://github.com/user-attachments/assets/ebbe9723-de0b-4fcf-a527-b660b508bb6e" />

* 🌙 **Performance**: loops per second: nr of times main loop is executed
* 🌙 **Performance**: loops per second: for effects, for drivers and overall. 🆕 Effects and drivers is the theoretical speed, if nothing else runs. overall lps is the really measured overall speed.
* 🌙 **Safe Mode**: After a crash, the device will start in Safe Mode disabling possible causes of crashes. See also [MoonLight](https://moonmodules.org/MoonLight/moonlight/overview/). In case of safe mode, the statusbar will show a shield: 🛡️. Try to find the reason of the crash and correct and restart the device. If no crash, it will go out of safe mode.
* 🌙 **Firmware Target**: Which firmware has been installed, see [MoonLight Installer](https://moonmodules.org/MoonLight/gettingstarted/installer/)
* 🌙 **Firmware Date**: What is the date the firmware is created (format YYYYMMDDHH)
Expand Down
76 changes: 54 additions & 22 deletions interface/src/lib/components/moonbase/FieldRenderer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import FileEditWidget from '$lib/components/moonbase/FileEditWidget.svelte';
import SearchableDropdown from '$lib/components/moonbase/SearchableDropdown.svelte';
import { initCap, getTimeAgo } from '$lib/stores/moonbase_utilities';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -133,6 +134,13 @@
let clickTimeout: ReturnType<typeof setTimeout> | null = null;
let preventClick = false;

// Find currently selected node object by matching value string
$: selectedNode =
property.type === 'selectFile' && Array.isArray(property.values)
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
property.values.find((v: any) => v.name === value)
: undefined;

// inspired by WLED
function genPalPrev(hexString: string) {
if (!hexString) return '';
Expand Down Expand Up @@ -181,35 +189,59 @@
{:else}
<span>{value}</span>
{/if}
{:else if property.type == 'select' || property.type == 'selectFile'}
{:else if property.type == 'select'}
<select bind:value onchange={onChange} class="select">
{#each property.values as optionLabel, index (index)}
<option value={property.type == 'selectFile' ? optionLabel : index}>
<option value={index}>
{optionLabel}
</option>
{/each}
</select>
{#if property.type == 'selectFile'}
<FileEditWidget path={value} showEditor={false} />
{/if}
{:else if property.type == 'selectFile'}
<SearchableDropdown
values={property.values ?? []}
isSelected={(val) => value === val.name}
onSelect={(val, _idx, event) => {
value = val.name;
onChange(event);
}}
{disabled}
showTags={true}
minWidth="min-w-60"
>
<span slot="trigger" class="flex-1 truncate text-left text-sm"
>{selectedNode?.name ?? value ?? ''}</span
>
</SearchableDropdown>
<FileEditWidget path={value} showEditor={false} />
{:else if property.type == 'palette'}
<div style="display: flex; gap: 8px; align-items: center;">
<select bind:value onchange={onChange} class="select">
{#each property.values as val, index (index)}
<option value={index}>{val.name}</option>
{/each}
</select>
<div class="palette-preview" style={genPalPrev(property.values[value]?.colors)}></div>
</div>

<style>
.palette-preview {
width: 250px;
height: 40px;
border: 1px solid #ccc;
border-radius: 3px;
}
</style>
<SearchableDropdown
values={property.values ?? []}
isSelected={(_val, idx) => value === idx}
onSelect={(_val, idx, event) => {
value = idx;
onChange(event);
}}
{disabled}
minWidth="min-w-122"
>
<span slot="trigger" class="flex items-center gap-2">
<span
style="{genPalPrev(
property.values[value]?.colors
)} width:240px; height:30px; border-radius:2px; flex-shrink:0; display:inline-block; border:1px solid rgba(128,128,128,0.25);"
></span>
<span class="flex-1 truncate text-left text-sm">{property.values[value]?.name ?? ''}</span>
</span>
<span slot="item" let:val class="flex items-center gap-2">
<span
style="{genPalPrev(
val.colors
)} width:240px; height:20px; border-radius:2px; flex-shrink:0; display:inline-block; border:1px solid rgba(128,128,128,0.25);"
></span>
<span class="truncate text-sm">{val.name}</span>
</span>
</SearchableDropdown>
{:else if property.type == 'checkbox'}
<input type="checkbox" class="toggle toggle-primary" bind:checked={value} onchange={onChange} />
{:else if property.type == 'slider'}
Expand Down
242 changes: 242 additions & 0 deletions interface/src/lib/components/moonbase/SearchableDropdown.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
<!--
@title MoonBase
@file SearchableDropdown.svelte
@repo https://github.com/MoonModules/MoonLight, submit changes to this file as PRs
@Authors https://github.com/MoonModules/MoonLight/commits/main
@Copyright © 2026 GitHub MoonLight Commit Authors
@license GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007
@license For non GPL-v3 usage, commercial licenses must be purchased. Contact us for more information.

Reusable searchable dropdown with category tabs and tag cloud filtering.
Used by FieldRenderer for both node selector (selectFile) and palette selector.
-->

<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { positionDropdown, extractEmojis } from '$lib/stores/moonbase_utilities';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export let values: any[] = [];
export let isSelected: (val: any, index: number) => boolean; // eslint-disable-line @typescript-eslint/no-explicit-any
export let onSelect: (val: any, index: number, event: Event) => void; // eslint-disable-line @typescript-eslint/no-explicit-any
export let disabled = false;
export let showTags = false;
export let minWidth = 'min-w-72';

let open = false;
let dropdownEl: HTMLElement | undefined;
let listEl: HTMLElement | undefined;
let search = '';
let categoryFilter = '';
let tagFilter = '';
let activeIndex = -1;

$: categories = [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...new Set(values.map((v: any) => v.category).filter((c: string) => c))
] as string[];

$: tags = showTags
? (() => {
const t = new Set<string>();
for (const v of values) {
for (const e of extractEmojis(v.name || '')) t.add(e);
}
return [...t];
})()
: [];

$: filtered = values
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((v: any, i: number) => ({ ...v, _sd_idx: i }))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter((v: any) => {
const name: string = v.name || '';
if (categoryFilter && v.category !== categoryFilter) return false;
if (tagFilter && !extractEmojis(name).includes(tagFilter)) return false;
if (search && !name.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});

async function openDropdown() {
open = true;
search = '';
activeIndex = -1;
await tick();
if (!listEl || !dropdownEl) return;
const triggerEl = dropdownEl.querySelector('button') as HTMLElement;
positionDropdown(triggerEl, listEl);
// Initialise activeIndex to the currently selected item
activeIndex = filtered.findIndex((v) => isSelected(v, v._sd_idx));
// Scroll selected item to center
const selectedEl = listEl.querySelector('[aria-selected="true"]') as HTMLElement | null;
if (selectedEl) {
const listHeight = listEl.clientHeight;
listEl.scrollTop = selectedEl.offsetTop - listHeight / 2 + selectedEl.offsetHeight / 2;
}
// Focus search input
const searchInput = listEl.querySelector('input[type="text"]') as HTMLInputElement;
if (searchInput) searchInput.focus();
}

export function toggle() {
if (!disabled) {
if (!open) openDropdown();
else open = false;
}
}

function closeOnOutsideClick(e: MouseEvent) {
if (open && dropdownEl && !dropdownEl.contains(e.target as Node)) {
open = false;
}
}

function handleKeydown(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Escape') {
e.preventDefault();
open = false;
const triggerEl = dropdownEl?.querySelector('button') as HTMLElement | null;
triggerEl?.focus();
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (!listEl) return;
const options = Array.from(listEl.querySelectorAll('[role="option"]')) as HTMLElement[];
const count = options.length;
if (count === 0) return;
activeIndex =
e.key === 'ArrowDown' ? Math.min(activeIndex + 1, count - 1) : Math.max(activeIndex - 1, 0);
options[activeIndex]?.focus();
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
const val = filtered[activeIndex];
if (val) {
open = false;
onSelect(val, val._sd_idx, e as unknown as Event);
}
}
}

function handleTriggerKeydown(e: KeyboardEvent) {
if (disabled) return;
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!open) openDropdown();
}
}

onMount(() => {
window.addEventListener('mousedown', closeOnOutsideClick);
window.addEventListener('keydown', handleKeydown);
});

onDestroy(() => {
window.removeEventListener('mousedown', closeOnOutsideClick);
window.removeEventListener('keydown', handleKeydown);
});

// Sticky top offset depends on how many header rows are visible
$: hasCategoryRow = categories.length > 1;
$: hasTagRow = tags.length > 0;
// search bar is ~2.5rem, category row is ~2.25rem
$: tagTopPx = hasCategoryRow ? '4.75rem' : '2.5rem';
</script>

<div class="relative" bind:this={dropdownEl}>
<button
type="button"
class="select flex {minWidth} cursor-pointer items-center gap-2"
{disabled}
aria-haspopup="listbox"
aria-expanded={open}
onclick={() => toggle()}
onkeydown={handleTriggerKeydown}
>
<slot name="trigger">
<span class="flex-1 truncate text-left text-sm">Select...</span>
</slot>
<span class="ml-1 text-xs opacity-60">&#9662;</span>
</button>
{#if open}
<div
bind:this={listEl}
role="listbox"
class="border-base-300 bg-base-100 z-50 {minWidth} max-h-96 overflow-y-auto rounded border shadow-xl"
>
<!-- Search input -->
<div class="border-base-300 sticky top-0 z-10 border-b p-2">
<input
type="text"
class="input input-sm w-full"
placeholder="Search..."
bind:value={search}
/>
</div>
<!-- Category tabs -->
{#if hasCategoryRow}
<div class="border-base-300 sticky top-10 z-10 flex flex-wrap gap-1 border-b p-1">
<button
type="button"
class="btn btn-xs {categoryFilter === '' ? 'btn-primary' : 'btn-ghost'}"
onclick={() => {
categoryFilter = '';
}}>All</button
>
{#each categories as cat (cat)}
<button
type="button"
class="btn btn-xs {categoryFilter === cat ? 'btn-primary' : 'btn-ghost'}"
onclick={() => {
categoryFilter = categoryFilter === cat ? '' : cat;
}}>{cat}</button
>
{/each}
</div>
{/if}
<!-- Tag cloud -->
{#if hasTagRow}
<div
class="border-base-300 sticky z-10 flex flex-wrap gap-1 border-b p-1"
style="top: {tagTopPx}"
>
{#each tags as tag (tag)}
<button
type="button"
class="btn btn-xs btn-circle {tagFilter === tag ? 'btn-accent' : 'btn-ghost'}"
onclick={() => {
tagFilter = tagFilter === tag ? '' : tag;
}}
title={tag}>{tag}</button
>
{/each}
</div>
{/if}
<!-- Items -->
{#each filtered as val (val._sd_idx)}
<button
type="button"
role="option"
aria-selected={isSelected(val, val._sd_idx)}
class="hover:bg-base-200 flex w-full cursor-pointer items-center gap-2 px-2 py-1.5 {isSelected(
val,
val._sd_idx
)
? 'bg-base-300'
: ''}"
onclick={(event) => {
open = false;
onSelect(val, val._sd_idx, event);
}}
>
<slot name="item" {val} index={val._sd_idx}>
<span class="truncate text-sm">{val.name}</span>
</slot>
</button>
{/each}
{#if filtered.length === 0}
<div class="p-2 text-sm opacity-50">No matches</div>
{/if}
</div>
{/if}
</div>
8 changes: 6 additions & 2 deletions interface/src/lib/stores/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ let analytics_data = {
fs_used: <number[]>[],
fs_total: <number[]>[],
core_temp: <number[]>[],
lps: <number[]>[], // 🌙
lps_all: <number[]>[], // 🌙
lps_effects: <number[]>[], // 🌙
lps_drivers: <number[]>[], // 🌙
free_psram: <number[]>[],
used_psram: <number[]>[],
psram_size: <number[]>[],
Expand Down Expand Up @@ -42,7 +44,9 @@ function createAnalytics() {
fs_used: [...analytics_data.fs_used, content.fs_used / 1000].slice(-maxAnalyticsData),
fs_total: [...analytics_data.fs_total, content.fs_total / 1000].slice(-maxAnalyticsData),
core_temp: [...analytics_data.core_temp, content.core_temp].slice(-maxAnalyticsData),
lps: [...analytics_data.lps, content.lps].slice(-maxAnalyticsData), // 🌙
lps_all: [...analytics_data.lps_all, content.lps_all].slice(-maxAnalyticsData), // 🌙
lps_effects: [...analytics_data.lps_effects, content.lps_effects].slice(-maxAnalyticsData), // 🌙
lps_drivers: [...analytics_data.lps_drivers, content.lps_drivers].slice(-maxAnalyticsData), // 🌙
free_psram: [...analytics_data.free_psram, content.free_psram / 1000].slice(-maxAnalyticsData),
used_psram: [...analytics_data.used_psram, content.used_psram / 1000].slice(-maxAnalyticsData),
psram_size: [...analytics_data.psram_size, content.psram_size / 1000].slice(-maxAnalyticsData),
Expand Down
Loading
Loading