@@ -53,6 +53,7 @@ import {
5353} from "../keybindings/keyCodes.js" ;
5454import { hitTestFocusable } from "../layout/hitTest.js" ;
5555import { type LayoutTree , layout } from "../layout/layout.js" ;
56+ import { calculateAnchorPosition } from "../layout/positioning.js" ;
5657import { measureTextCells } from "../layout/textMeasure.js" ;
5758import type { Rect } from "../layout/types.js" ;
5859import { PERF_DETAIL_ENABLED , PERF_ENABLED , perfMarkEnd , perfMarkStart } from "../perf/perf.js" ;
@@ -247,6 +248,7 @@ export class WidgetRenderer<S> {
247248 private committedRoot : RuntimeInstance | null = null ;
248249 private layoutTree : LayoutTree | null = null ;
249250 private renderTick = 0 ;
251+ private lastViewport : Viewport = Object . freeze ( { cols : 0 , rows : 0 } ) ;
250252
251253 /* --- Focus/Interaction State --- */
252254 private focusState : FocusManagerState = createFocusManagerState ( ) ;
@@ -256,6 +258,7 @@ export class WidgetRenderer<S> {
256258 private baseEnabledById : ReadonlyMap < string , boolean > = new Map < string , boolean > ( ) ;
257259 private pressableIds : ReadonlySet < string > = new Set < string > ( ) ;
258260 private pressedId : string | null = null ;
261+ private pressedDropdown : Readonly < { id : string ; itemId : string } > | null = null ;
259262 private pressedVirtualList : Readonly < { id : string ; index : number } > | null = null ;
260263 private pressedTable : Readonly < { id : string ; rowIndex : number } > | null = null ;
261264 private pressedTableHeader : Readonly < { id : string ; columnIndex : number } > | null = null ;
@@ -528,6 +531,96 @@ export class WidgetRenderer<S> {
528531 }
529532
530533 if ( event . kind === "mouse" ) {
534+ const topDropdownId =
535+ this . dropdownStack . length > 0
536+ ? ( this . dropdownStack [ this . dropdownStack . length - 1 ] ?? null )
537+ : null ;
538+ if ( topDropdownId ) {
539+ const dropdown = this . dropdownById . get ( topDropdownId ) ;
540+ const dropdownRect = dropdown ? this . computeDropdownRect ( dropdown ) : null ;
541+ if ( dropdown && dropdownRect && dropdownRect . w > 0 && dropdownRect . h > 0 ) {
542+ const inside =
543+ event . x >= dropdownRect . x &&
544+ event . x < dropdownRect . x + dropdownRect . w &&
545+ event . y >= dropdownRect . y &&
546+ event . y < dropdownRect . y + dropdownRect . h ;
547+
548+ const contentX = dropdownRect . x + 1 ;
549+ const contentY = dropdownRect . y + 1 ;
550+ const contentW = Math . max ( 0 , dropdownRect . w - 2 ) ;
551+ const contentH = Math . max ( 0 , dropdownRect . h - 2 ) ;
552+ const inContent =
553+ event . x >= contentX &&
554+ event . x < contentX + contentW &&
555+ event . y >= contentY &&
556+ event . y < contentY + contentH ;
557+ const itemIndex = inContent ? event . y - contentY : null ;
558+
559+ const MOUSE_KIND_DOWN = 3 ;
560+ const MOUSE_KIND_UP = 4 ;
561+
562+ if ( event . mouseKind === MOUSE_KIND_DOWN ) {
563+ this . pressedDropdown = null ;
564+
565+ if ( ! inside ) {
566+ if ( dropdown . onClose ) {
567+ try {
568+ dropdown . onClose ( ) ;
569+ } catch {
570+ // Swallow close callback errors to preserve routing determinism.
571+ }
572+ }
573+ return ROUTE_RENDER ;
574+ }
575+
576+ if ( itemIndex !== null && itemIndex >= 0 && itemIndex < dropdown . items . length ) {
577+ const item = dropdown . items [ itemIndex ] ;
578+ if ( item && ! item . divider && item . disabled !== true ) {
579+ const prevSelected = this . dropdownSelectedIndexById . get ( topDropdownId ) ?? 0 ;
580+ this . dropdownSelectedIndexById . set ( topDropdownId , itemIndex ) ;
581+ this . pressedDropdown = Object . freeze ( { id : topDropdownId , itemId : item . id } ) ;
582+ return Object . freeze ( { needsRender : itemIndex !== prevSelected } ) ;
583+ }
584+ }
585+
586+ // Click inside dropdown but not on a selectable item: consume.
587+ return ROUTE_NO_RENDER ;
588+ }
589+
590+ if ( event . mouseKind === MOUSE_KIND_UP ) {
591+ const pressed = this . pressedDropdown ;
592+ this . pressedDropdown = null ;
593+
594+ if ( pressed && pressed . id === topDropdownId && itemIndex !== null ) {
595+ const item = dropdown . items [ itemIndex ] ;
596+ if ( item && item . id === pressed . itemId && ! item . divider && item . disabled !== true ) {
597+ if ( dropdown . onSelect ) {
598+ try {
599+ dropdown . onSelect ( item ) ;
600+ } catch {
601+ // Swallow select callback errors to preserve routing determinism.
602+ }
603+ }
604+ if ( dropdown . onClose ) {
605+ try {
606+ dropdown . onClose ( ) ;
607+ } catch {
608+ // Swallow close callback errors to preserve routing determinism.
609+ }
610+ }
611+ return ROUTE_RENDER ;
612+ }
613+ }
614+
615+ // Mouse up while dropdown is open: consume.
616+ return ROUTE_NO_RENDER ;
617+ }
618+
619+ // Dropdown open: block mouse events to lower layers.
620+ return ROUTE_NO_RENDER ;
621+ }
622+ }
623+
531624 const hit = hitTestLayers ( this . layerRegistry , event . x , event . y ) ;
532625 if ( hit . blocked ) {
533626 const blocking = hit . blockingLayer ;
@@ -1827,6 +1920,43 @@ export class WidgetRenderer<S> {
18271920 }
18281921 }
18291922
1923+ private computeDropdownRect ( props : DropdownProps ) : Rect | null {
1924+ const viewport = this . lastViewport ;
1925+ if ( viewport . cols <= 0 || viewport . rows <= 0 ) return null ;
1926+
1927+ const anchor = this . rectById . get ( props . anchorId ) ?? null ;
1928+ if ( ! anchor ) return null ;
1929+
1930+ const items = Array . isArray ( props . items ) ? props . items : [ ] ;
1931+ let maxLabelW = 0 ;
1932+ let maxShortcutW = 0 ;
1933+ for ( const item of items ) {
1934+ if ( ! item || item . divider ) continue ;
1935+ const labelW = measureTextCells ( item . label ) ;
1936+ if ( labelW > maxLabelW ) maxLabelW = labelW ;
1937+ const shortcut = item . shortcut ;
1938+ if ( shortcut && shortcut . length > 0 ) {
1939+ const shortcutW = measureTextCells ( shortcut ) ;
1940+ if ( shortcutW > maxShortcutW ) maxShortcutW = shortcutW ;
1941+ }
1942+ }
1943+
1944+ const gapW = maxShortcutW > 0 ? 1 : 0 ;
1945+ const contentW = Math . max ( 1 , maxLabelW + gapW + maxShortcutW ) ;
1946+ const totalW = Math . max ( 2 , contentW + 2 ) ; // +2 for border
1947+ const totalH = Math . max ( 2 , items . length + 2 ) ; // +2 for border
1948+
1949+ const pos = calculateAnchorPosition ( {
1950+ anchor,
1951+ overlaySize : { w : totalW , h : totalH } ,
1952+ position : props . position ?? "below-start" ,
1953+ viewport : { x : 0 , y : 0 , width : viewport . cols , height : viewport . rows } ,
1954+ gap : 0 ,
1955+ flip : true ,
1956+ } ) ;
1957+ return pos . rect ;
1958+ }
1959+
18301960 /**
18311961 * Execute view function, commit tree, compute layout, and render to drawlist.
18321962 *
@@ -1860,6 +1990,7 @@ export class WidgetRenderer<S> {
18601990 } ;
18611991 }
18621992
1993+ this . lastViewport = viewport ;
18631994 this . builder . reset ( ) ;
18641995
18651996 let entered = false ;
@@ -2643,6 +2774,7 @@ export class WidgetRenderer<S> {
26432774 commandPaletteItemsById : this . commandPaletteItemsById ,
26442775 commandPaletteLoadingById : this . commandPaletteLoadingById ,
26452776 toolApprovalFocusedActionById : this . toolApprovalFocusedActionById ,
2777+ dropdownSelectedIndexById : this . dropdownSelectedIndexById ,
26462778 diffViewerFocusedHunkById : this . diffViewerFocusedHunkById ,
26472779 diffViewerExpandedHunksById : this . diffViewerExpandedHunksById ,
26482780 layoutIndex : this . _pooledRectByInstanceId ,
0 commit comments