1
1
// Copyright 2025, Command Line Inc.
2
2
// SPDX-License-Identifier: Apache-2.0
3
3
4
- import Logo from "@/app/asset/logo.svg" ; // Your SVG logo
4
+ import logoUrl from "@/app/asset/logo.svg?url" ;
5
5
import { atoms , replaceBlock } from "@/app/store/global" ;
6
6
import { isBlank , makeIconClass } from "@/util/util" ;
7
7
import clsx from "clsx" ;
8
8
import { atom , useAtomValue } from "jotai" ;
9
- import React , { useLayoutEffect , useMemo , useRef , useState } from "react" ;
9
+ import React , { useCallback , useEffect , useLayoutEffect , useRef , useState } from "react" ;
10
10
11
11
function sortByDisplayOrder ( wmap : { [ key : string ] : WidgetConfigType } | null | undefined ) : WidgetConfigType [ ] {
12
12
if ( ! wmap ) return [ ] ;
@@ -21,13 +21,31 @@ export class LauncherViewModel implements ViewModel {
21
21
viewName = atom ( "Widget Launcher" ) ;
22
22
viewComponent = LauncherView ;
23
23
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
+ }
24
33
}
25
34
26
35
const LauncherView : React . FC < ViewComponentProps < LauncherViewModel > > = ( { blockId, model } ) => {
27
36
const fullConfig = useAtomValue ( atoms . fullConfigAtom ) ;
28
37
const widgetMap = fullConfig ?. widgets || { } ;
29
38
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
+ ) ;
31
49
32
50
// Container measurement
33
51
const containerRef = useRef < HTMLDivElement > ( null ) ;
@@ -50,31 +68,28 @@ const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId
50
68
} , [ ] ) ;
51
69
52
70
// 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 ;
57
75
58
- // Dynamic logo sizing: 30% of container width, clamped between 100 and 300.
59
76
const calculatedLogoWidth = containerSize . width * 0.3 ;
60
77
const logoWidth = containerSize . width >= 100 ? Math . min ( Math . max ( calculatedLogoWidth , 100 ) , 300 ) : 0 ;
61
78
const showLogo = logoWidth >= 100 ;
62
-
63
- // Available height for the grid (after subtracting logo space)
64
79
const availableHeight = containerSize . height - ( showLogo ? logoWidth + MARGIN_BOTTOM : 0 ) ;
65
80
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 ) {
69
84
return { columns : 1 , tileWidth : 90 , tileHeight : 90 , showLabel : true } ;
70
85
}
71
86
let bestColumns = 1 ;
72
87
let bestTileSize = 0 ;
73
88
let bestTileWidth = 90 ;
74
89
let bestTileHeight = 90 ;
75
90
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 ) ;
78
93
const tileWidth = ( containerSize . width - ( cols - 1 ) * GAP ) / cols ;
79
94
const tileHeight = ( availableHeight - ( rows - 1 ) * GAP ) / rows ;
80
95
const currentTileSize = Math . min ( tileWidth , tileHeight ) ;
@@ -87,12 +102,12 @@ const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId
87
102
}
88
103
}
89
104
return { columns : bestColumns , tileWidth : bestTileWidth , tileHeight : bestTileHeight , showLabel } ;
90
- } , [ containerSize , availableHeight , widgetCount ] ) ;
105
+ } , [ containerSize , availableHeight , filteredWidgets . length ] ) ;
91
106
92
- // Clamp tile sizes so they don't exceed MAX_TILE_SIZE.
93
107
const finalTileWidth = Math . min ( gridLayout . tileWidth , MAX_TILE_SIZE ) ;
94
108
const finalTileHeight = gridLayout . showLabel ? Math . min ( gridLayout . tileHeight , MAX_TILE_SIZE ) : finalTileWidth ;
95
109
110
+ // Handle widget selection and launch
96
111
const handleWidgetSelect = async ( widget : WidgetConfigType ) => {
97
112
try {
98
113
await replaceBlock ( blockId , widget . blockdef ) ;
@@ -101,12 +116,81 @@ const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId
101
116
}
102
117
} ;
103
118
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
+
104
178
return (
105
179
< 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 */ }
107
191
{ showLogo && (
108
192
< 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 " />
110
194
</ div >
111
195
) }
112
196
@@ -117,38 +201,49 @@ const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId
117
201
gridTemplateColumns : `repeat(${ gridLayout . columns } , ${ finalTileWidth } px)` ,
118
202
} }
119
203
>
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
+ />
149
227
</ 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
+ ) }
152
247
</ div >
153
248
</ div >
154
249
) ;
0 commit comments