From d89ccf2250256bb67cdd9acfde1b679f39ca1f95 Mon Sep 17 00:00:00 2001
From: Miguel Solorio <miguelsolorio@google.com>
Date: Fri, 11 Jul 2025 18:05:21 -0700
Subject: Add scrolling to theme dialog (#3895)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
---
 .../src/ui/components/shared/RadioButtonSelect.tsx | 193 +++++++++++----------
 1 file changed, 104 insertions(+), 89 deletions(-)

(limited to 'packages/cli/src/ui/components/shared/RadioButtonSelect.tsx')

diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
index fab0615c..c3829bb4 100644
--- a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
+++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
@@ -4,12 +4,8 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-import React from 'react';
-import { Text, Box } from 'ink';
-import SelectInput, {
-  type ItemProps as InkSelectItemProps,
-  type IndicatorProps as InkSelectIndicatorProps,
-} from 'ink-select-input';
+import React, { useEffect, useState } from 'react';
+import { Text, Box, useInput } from 'ink';
 import { Colors } from '../../colors.js';
 
 /**
@@ -20,6 +16,8 @@ export interface RadioSelectItem<T> {
   label: string;
   value: T;
   disabled?: boolean;
+  themeNameDisplay?: string;
+  themeTypeDisplay?: string;
 }
 
 /**
@@ -28,115 +26,132 @@ export interface RadioSelectItem<T> {
  */
 export interface RadioButtonSelectProps<T> {
   /** An array of items to display as radio options. */
-  items: Array<
-    RadioSelectItem<T> & {
-      themeNameDisplay?: string;
-      themeTypeDisplay?: string;
-    }
-  >;
-
+  items: Array<RadioSelectItem<T>>;
   /** The initial index selected */
   initialIndex?: number;
-
   /** Function called when an item is selected. Receives the `value` of the selected item. */
   onSelect: (value: T) => void;
-
   /** Function called when an item is highlighted. Receives the `value` of the selected item. */
   onHighlight?: (value: T) => void;
-
   /** Whether this select input is currently focused and should respond to input. */
   isFocused?: boolean;
+  /** Whether to show the scroll arrows. */
+  showScrollArrows?: boolean;
+  /** The maximum number of items to show at once. */
+  maxItemsToShow?: number;
 }
 
 /**
- * A specialized SelectInput component styled to look like radio buttons.
- * It uses '◉' for selected and '○' for unselected items.
+ * A custom component that displays a list of items with radio buttons,
+ * supporting scrolling and keyboard navigation.
  *
  * @template T The type of the value associated with each radio item.
  */
 export function RadioButtonSelect<T>({
   items,
-  initialIndex,
+  initialIndex = 0,
   onSelect,
   onHighlight,
-  isFocused, // This prop indicates if the current RadioButtonSelect group is focused
+  isFocused,
+  showScrollArrows = true,
+  maxItemsToShow = 10,
 }: RadioButtonSelectProps<T>): React.JSX.Element {
-  const handleSelect = (item: RadioSelectItem<T>) => {
-    onSelect(item.value);
-  };
-  const handleHighlight = (item: RadioSelectItem<T>) => {
-    if (onHighlight) {
-      onHighlight(item.value);
-    }
-  };
+  const [activeIndex, setActiveIndex] = useState(initialIndex);
+  const [scrollOffset, setScrollOffset] = useState(0);
 
-  /**
-   * Custom indicator component displaying radio button style (◉/○).
-   * Color changes based on whether the item is selected and if its group is focused.
-   */
-  function DynamicRadioIndicator({
-    isSelected = false,
-  }: InkSelectIndicatorProps): React.JSX.Element {
-    return (
-      <Box minWidth={2} flexShrink={0}>
-        <Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
-          {isSelected ? '●' : '○'}
-        </Text>
-      </Box>
+  useEffect(() => {
+    const newScrollOffset = Math.max(
+      0,
+      Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
     );
-  }
+    if (activeIndex < scrollOffset) {
+      setScrollOffset(activeIndex);
+    } else if (activeIndex >= scrollOffset + maxItemsToShow) {
+      setScrollOffset(newScrollOffset);
+    }
+  }, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
 
-  /**
-   * Custom item component for displaying the label.
-   * Color changes based on whether the item is selected and if its group is focused.
-   * Now also handles displaying theme type with custom color.
-   */
-  function CustomThemeItemComponent(
-    props: InkSelectItemProps,
-  ): React.JSX.Element {
-    const { isSelected = false, label } = props;
-    const itemWithThemeProps = props as typeof props & {
-      themeNameDisplay?: string;
-      themeTypeDisplay?: string;
-      disabled?: boolean;
-    };
+  useInput(
+    (input, key) => {
+      if (input === 'k' || key.upArrow) {
+        const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
+        setActiveIndex(newIndex);
+        onHighlight?.(items[newIndex]!.value);
+      }
+      if (input === 'j' || key.downArrow) {
+        const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
+        setActiveIndex(newIndex);
+        onHighlight?.(items[newIndex]!.value);
+      }
+      if (key.return) {
+        onSelect(items[activeIndex]!.value);
+      }
 
-    let textColor = Colors.Foreground;
-    if (isSelected) {
-      textColor = Colors.AccentGreen;
-    } else if (itemWithThemeProps.disabled === true) {
-      textColor = Colors.Gray;
-    }
+      // Enable selection directly from number keys.
+      if (/^[1-9]$/.test(input)) {
+        const targetIndex = Number.parseInt(input, 10) - 1;
+        if (targetIndex >= 0 && targetIndex < visibleItems.length) {
+          const selectedItem = visibleItems[targetIndex];
+          if (selectedItem) {
+            onSelect?.(selectedItem.value);
+          }
+        }
+      }
+    },
+    { isActive: isFocused && items.length > 0 },
+  );
 
-    if (
-      itemWithThemeProps.themeNameDisplay &&
-      itemWithThemeProps.themeTypeDisplay
-    ) {
-      return (
-        <Text color={textColor} wrap="truncate">
-          {itemWithThemeProps.themeNameDisplay}{' '}
-          <Text color={Colors.Gray}>{itemWithThemeProps.themeTypeDisplay}</Text>
+  const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
+
+  return (
+    <Box flexDirection="column">
+      {showScrollArrows && (
+        <Text color={scrollOffset > 0 ? Colors.Foreground : Colors.Gray}>
+          ▲
         </Text>
-      );
-    }
+      )}
+      {visibleItems.map((item, index) => {
+        const itemIndex = scrollOffset + index;
+        const isSelected = activeIndex === itemIndex;
 
-    return (
-      <Text color={textColor} wrap="truncate">
-        {label}
-      </Text>
-    );
-  }
+        let textColor = Colors.Foreground;
+        if (isSelected) {
+          textColor = Colors.AccentGreen;
+        } else if (item.disabled) {
+          textColor = Colors.Gray;
+        }
 
-  initialIndex = initialIndex ?? 0;
-  return (
-    <SelectInput
-      indicatorComponent={DynamicRadioIndicator}
-      itemComponent={CustomThemeItemComponent}
-      items={items}
-      initialIndex={initialIndex}
-      onSelect={handleSelect}
-      onHighlight={handleHighlight}
-      isFocused={isFocused}
-    />
+        return (
+          <Box key={item.label}>
+            <Box minWidth={2} flexShrink={0}>
+              <Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
+                {isSelected ? '●' : '○'}
+              </Text>
+            </Box>
+            {item.themeNameDisplay && item.themeTypeDisplay ? (
+              <Text color={textColor} wrap="truncate">
+                {item.themeNameDisplay}{' '}
+                <Text color={Colors.Gray}>{item.themeTypeDisplay}</Text>
+              </Text>
+            ) : (
+              <Text color={textColor} wrap="truncate">
+                {item.label}
+              </Text>
+            )}
+          </Box>
+        );
+      })}
+      {showScrollArrows && (
+        <Text
+          color={
+            scrollOffset + maxItemsToShow < items.length
+              ? Colors.Foreground
+              : Colors.Gray
+          }
+        >
+          ▼
+        </Text>
+      )}
+    </Box>
   );
 }
-- 
cgit v1.2.3

