Skip to content

Commit ad80b96

Browse files
committed
searchin launcher
1 parent 8c01f82 commit ad80b96

File tree

1 file changed

+145
-50
lines changed

1 file changed

+145
-50
lines changed

frontend/app/view/launcher/launcher.tsx

Lines changed: 145 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
// Copyright 2025, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import Logo from "@/app/asset/logo.svg"; // Your SVG logo
4+
import logoUrl from "@/app/asset/logo.svg?url";
55
import { atoms, replaceBlock } from "@/app/store/global";
66
import { isBlank, makeIconClass } from "@/util/util";
77
import clsx from "clsx";
88
import { atom, useAtomValue } from "jotai";
9-
import React, { useLayoutEffect, useMemo, useRef, useState } from "react";
9+
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
1010

1111
function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType } | null | undefined): WidgetConfigType[] {
1212
if (!wmap) return [];
@@ -21,13 +21,31 @@ export class LauncherViewModel implements ViewModel {
2121
viewName = atom("Widget Launcher");
2222
viewComponent = LauncherView;
2323
noHeader = atom(true);
24+
inputRef = { current: null } as React.RefObject<HTMLInputElement>;
25+
26+
giveFocus(): boolean {
27+
if (this.inputRef.current) {
28+
this.inputRef.current.focus();
29+
return true;
30+
}
31+
return false;
32+
}
2433
}
2534

2635
const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId, model }) => {
2736
const fullConfig = useAtomValue(atoms.fullConfigAtom);
2837
const widgetMap = fullConfig?.widgets || {};
2938
const widgets = sortByDisplayOrder(widgetMap);
30-
const widgetCount = widgets.length;
39+
40+
// Search and selection state
41+
const [searchTerm, setSearchTerm] = useState("");
42+
const [selectedIndex, setSelectedIndex] = useState(0);
43+
44+
// Filter widgets based on search term
45+
const filteredWidgets = widgets.filter(
46+
(widget) =>
47+
!widget["display:hidden"] && (!searchTerm || widget.label?.toLowerCase().includes(searchTerm.toLowerCase()))
48+
);
3149

3250
// Container measurement
3351
const containerRef = useRef<HTMLDivElement>(null);
@@ -50,31 +68,28 @@ const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId
5068
}, []);
5169

5270
// Layout constants
53-
const GAP = 16; // gap between grid items (px)
54-
const LABEL_THRESHOLD = 60; // if tile height is below this, hide the label
55-
const MARGIN_BOTTOM = 24; // space below the logo
56-
const MAX_TILE_SIZE = 120; // max widget box size
71+
const GAP = 16;
72+
const LABEL_THRESHOLD = 60;
73+
const MARGIN_BOTTOM = 24;
74+
const MAX_TILE_SIZE = 120;
5775

58-
// Dynamic logo sizing: 30% of container width, clamped between 100 and 300.
5976
const calculatedLogoWidth = containerSize.width * 0.3;
6077
const logoWidth = containerSize.width >= 100 ? Math.min(Math.max(calculatedLogoWidth, 100), 300) : 0;
6178
const showLogo = logoWidth >= 100;
62-
63-
// Available height for the grid (after subtracting logo space)
6479
const availableHeight = containerSize.height - (showLogo ? logoWidth + MARGIN_BOTTOM : 0);
6580

66-
// Determine optimal grid layout based on container dimensions and widget count.
67-
const gridLayout = useMemo(() => {
68-
if (containerSize.width === 0 || availableHeight <= 0 || widgetCount === 0) {
81+
// Determine optimal grid layout
82+
const gridLayout = React.useMemo(() => {
83+
if (containerSize.width === 0 || availableHeight <= 0 || filteredWidgets.length === 0) {
6984
return { columns: 1, tileWidth: 90, tileHeight: 90, showLabel: true };
7085
}
7186
let bestColumns = 1;
7287
let bestTileSize = 0;
7388
let bestTileWidth = 90;
7489
let bestTileHeight = 90;
7590
let showLabel = true;
76-
for (let cols = 1; cols <= widgetCount; cols++) {
77-
const rows = Math.ceil(widgetCount / cols);
91+
for (let cols = 1; cols <= filteredWidgets.length; cols++) {
92+
const rows = Math.ceil(filteredWidgets.length / cols);
7893
const tileWidth = (containerSize.width - (cols - 1) * GAP) / cols;
7994
const tileHeight = (availableHeight - (rows - 1) * GAP) / rows;
8095
const currentTileSize = Math.min(tileWidth, tileHeight);
@@ -87,12 +102,12 @@ const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId
87102
}
88103
}
89104
return { columns: bestColumns, tileWidth: bestTileWidth, tileHeight: bestTileHeight, showLabel };
90-
}, [containerSize, availableHeight, widgetCount]);
105+
}, [containerSize, availableHeight, filteredWidgets.length]);
91106

92-
// Clamp tile sizes so they don't exceed MAX_TILE_SIZE.
93107
const finalTileWidth = Math.min(gridLayout.tileWidth, MAX_TILE_SIZE);
94108
const finalTileHeight = gridLayout.showLabel ? Math.min(gridLayout.tileHeight, MAX_TILE_SIZE) : finalTileWidth;
95109

110+
// Handle widget selection and launch
96111
const handleWidgetSelect = async (widget: WidgetConfigType) => {
97112
try {
98113
await replaceBlock(blockId, widget.blockdef);
@@ -101,12 +116,81 @@ const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId
101116
}
102117
};
103118

119+
// Keyboard navigation
120+
const handleKeyDown = useCallback(
121+
(e: KeyboardEvent) => {
122+
const rows = Math.ceil(filteredWidgets.length / gridLayout.columns);
123+
const currentRow = Math.floor(selectedIndex / gridLayout.columns);
124+
const currentCol = selectedIndex % gridLayout.columns;
125+
126+
switch (e.key) {
127+
case "ArrowUp":
128+
e.preventDefault();
129+
if (currentRow > 0) {
130+
const newIndex = selectedIndex - gridLayout.columns;
131+
if (newIndex >= 0) setSelectedIndex(newIndex);
132+
}
133+
break;
134+
case "ArrowDown":
135+
e.preventDefault();
136+
if (currentRow < rows - 1) {
137+
const newIndex = selectedIndex + gridLayout.columns;
138+
if (newIndex < filteredWidgets.length) setSelectedIndex(newIndex);
139+
}
140+
break;
141+
case "ArrowLeft":
142+
e.preventDefault();
143+
if (currentCol > 0) setSelectedIndex(selectedIndex - 1);
144+
break;
145+
case "ArrowRight":
146+
e.preventDefault();
147+
if (currentCol < gridLayout.columns - 1 && selectedIndex + 1 < filteredWidgets.length) {
148+
setSelectedIndex(selectedIndex + 1);
149+
}
150+
break;
151+
case "Enter":
152+
e.preventDefault();
153+
if (filteredWidgets[selectedIndex]) {
154+
handleWidgetSelect(filteredWidgets[selectedIndex]);
155+
}
156+
break;
157+
case "Escape":
158+
e.preventDefault();
159+
setSearchTerm("");
160+
setSelectedIndex(0);
161+
break;
162+
}
163+
},
164+
[selectedIndex, gridLayout.columns, filteredWidgets.length, handleWidgetSelect]
165+
);
166+
167+
// Set up keyboard listeners
168+
useEffect(() => {
169+
window.addEventListener("keydown", handleKeyDown);
170+
return () => window.removeEventListener("keydown", handleKeyDown);
171+
}, [handleKeyDown]);
172+
173+
// Reset selection when search term changes
174+
useEffect(() => {
175+
setSelectedIndex(0);
176+
}, [searchTerm]);
177+
104178
return (
105179
<div ref={containerRef} className="w-full h-full p-4 box-border flex flex-col items-center justify-center">
106-
{/* Logo wrapped in a div for proper scaling */}
180+
{/* Hidden input for search */}
181+
<input
182+
ref={model.inputRef}
183+
type="text"
184+
value={searchTerm}
185+
onChange={(e) => setSearchTerm(e.target.value)}
186+
className="sr-only"
187+
aria-label="Search widgets"
188+
/>
189+
190+
{/* Logo */}
107191
{showLogo && (
108192
<div className="mb-6" style={{ width: logoWidth, maxWidth: 300 }}>
109-
<Logo className="w-full h-auto filter grayscale brightness-90 opacity-90" />
193+
<img src={logoUrl} className="w-full h-auto filter grayscale brightness-70 opacity-70" alt="Logo" />
110194
</div>
111195
)}
112196

@@ -117,38 +201,49 @@ const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId
117201
gridTemplateColumns: `repeat(${gridLayout.columns}, ${finalTileWidth}px)`,
118202
}}
119203
>
120-
{widgets.map((widget, index) => {
121-
if (widget["display:hidden"]) return null;
122-
return (
123-
<div
124-
key={index}
125-
onClick={() => handleWidgetSelect(widget)}
126-
title={widget.description || widget.label}
127-
className={clsx(
128-
"flex flex-col items-center justify-center cursor-pointer rounded-md p-2 text-center",
129-
"bg-white/5 hover:bg-white/10",
130-
"text-secondary hover:text-white"
131-
)}
132-
style={{
133-
width: finalTileWidth,
134-
height: finalTileHeight,
135-
}}
136-
>
137-
<div style={{ color: widget.color }}>
138-
<i
139-
className={makeIconClass(widget.icon, true, {
140-
defaultIcon: "browser",
141-
})}
142-
/>
143-
</div>
144-
{gridLayout.showLabel && !isBlank(widget.label) && (
145-
<div className="mt-1 w-full text-[11px] leading-4 overflow-hidden text-ellipsis whitespace-nowrap">
146-
{widget.label}
147-
</div>
148-
)}
204+
{filteredWidgets.map((widget, index) => (
205+
<div
206+
key={index}
207+
onClick={() => handleWidgetSelect(widget)}
208+
title={widget.description || widget.label}
209+
className={clsx(
210+
"flex flex-col items-center justify-center cursor-pointer rounded-md p-2 text-center",
211+
"transition-colors duration-150",
212+
index === selectedIndex
213+
? "bg-white/20 text-white"
214+
: "bg-white/5 hover:bg-white/10 text-secondary hover:text-white"
215+
)}
216+
style={{
217+
width: finalTileWidth,
218+
height: finalTileHeight,
219+
}}
220+
>
221+
<div style={{ color: widget.color }}>
222+
<i
223+
className={makeIconClass(widget.icon, true, {
224+
defaultIcon: "browser",
225+
})}
226+
/>
149227
</div>
150-
);
151-
})}
228+
{gridLayout.showLabel && !isBlank(widget.label) && (
229+
<div className="mt-1 w-full text-[11px] leading-4 overflow-hidden text-ellipsis whitespace-nowrap">
230+
{widget.label}
231+
</div>
232+
)}
233+
</div>
234+
))}
235+
</div>
236+
237+
{/* Search instructions */}
238+
<div className="mt-4 text-secondary text-xs">
239+
{filteredWidgets.length === 0 ? (
240+
<span>No widgets found. Press Escape to clear search.</span>
241+
) : (
242+
<span>
243+
{searchTerm == "" ? "Type to Filter" : "Searching " + '"' + searchTerm + '"'}, Enter to Launch,
244+
{searchTerm == "" ? "Arrow Keys to Navigate" : null}
245+
</span>
246+
)}
152247
</div>
153248
</div>
154249
);

0 commit comments

Comments
 (0)