2025-04-22 18:57:47 -07:00
/ * *
* @license
* Copyright 2025 Google LLC
* SPDX - License - Identifier : Apache - 2.0
* /
2025-07-11 18:05:21 -07:00
import React , { useCallback , useState } from 'react' ;
2025-05-01 10:34:07 -07:00
import { Box , Text , useInput } from 'ink' ;
2025-04-22 18:57:47 -07:00
import { Colors } from '../colors.js' ;
2025-05-01 10:34:07 -07:00
import { themeManager , DEFAULT_THEME } from '../themes/theme-manager.js' ;
2025-04-22 18:57:47 -07:00
import { RadioButtonSelect } from './shared/RadioButtonSelect.js' ;
2025-04-24 11:36:34 -07:00
import { DiffRenderer } from './messages/DiffRenderer.js' ;
import { colorizeCode } from '../utils/CodeColorizer.js' ;
2025-05-01 10:34:07 -07:00
import { LoadedSettings , SettingScope } from '../../config/settings.js' ;
2025-04-22 18:57:47 -07:00
interface ThemeDialogProps {
/** Callback function when a theme is selected */
2025-05-01 10:34:07 -07:00
onSelect : ( themeName : string | undefined , scope : SettingScope ) = > void ;
2025-04-24 11:36:34 -07:00
/** Callback function when a theme is highlighted */
2025-05-01 10:34:07 -07:00
onHighlight : ( themeName : string | undefined ) = > void ;
/** The settings object */
settings : LoadedSettings ;
2025-06-19 20:17:23 +00:00
availableTerminalHeight? : number ;
terminalWidth : number ;
2025-04-22 18:57:47 -07:00
}
2025-04-24 11:36:34 -07:00
export function ThemeDialog ( {
onSelect ,
onHighlight ,
2025-05-01 10:34:07 -07:00
settings ,
2025-06-19 20:17:23 +00:00
availableTerminalHeight ,
terminalWidth ,
2025-04-24 11:36:34 -07:00
} : ThemeDialogProps ) : React . JSX . Element {
2025-05-01 10:34:07 -07:00
const [ selectedScope , setSelectedScope ] = useState < SettingScope > (
SettingScope . User ,
) ;
2025-07-20 16:51:18 +09:00
// Track the currently highlighted theme name
const [ highlightedThemeName , setHighlightedThemeName ] = useState <
string | undefined
> ( settings . merged . theme || DEFAULT_THEME . name ) ;
2025-07-17 17:46:33 -07:00
2025-07-20 16:51:18 +09:00
// Generate theme items filtered by selected scope
const customThemes =
selectedScope === SettingScope . User
? settings . user . settings . customThemes || { }
: settings . merged . customThemes || { } ;
const builtInThemes = themeManager
. getAvailableThemes ( )
. filter ( ( theme ) = > theme . type !== 'custom' ) ;
const customThemeNames = Object . keys ( customThemes ) ;
const capitalize = ( s : string ) = > s . charAt ( 0 ) . toUpperCase ( ) + s . slice ( 1 ) ;
2025-05-08 16:00:55 -07:00
// Generate theme items
2025-07-20 16:51:18 +09:00
const themeItems = [
. . . builtInThemes . map ( ( theme ) = > ( {
label : theme.name ,
value : theme.name ,
themeNameDisplay : theme.name ,
themeTypeDisplay : capitalize ( theme . type ) ,
} ) ) ,
. . . customThemeNames . map ( ( name ) = > ( {
label : name ,
value : name ,
themeNameDisplay : name ,
themeTypeDisplay : 'Custom' ,
} ) ) ,
] ;
2025-05-01 10:34:07 -07:00
const [ selectInputKey , setSelectInputKey ] = useState ( Date . now ( ) ) ;
2025-07-20 16:51:18 +09:00
// Find the index of the selected theme, but only if it exists in the list
const selectedThemeName = settings . merged . theme || DEFAULT_THEME . name ;
2025-05-01 10:34:07 -07:00
const initialThemeIndex = themeItems . findIndex (
2025-07-20 16:51:18 +09:00
( item ) = > item . value === selectedThemeName ,
2025-05-01 10:34:07 -07:00
) ;
2025-07-20 16:51:18 +09:00
// If not found, fallback to the first theme
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0 ;
2025-05-01 10:34:07 -07:00
const scopeItems = [
{ label : 'User Settings' , value : SettingScope.User } ,
{ label : 'Workspace Settings' , value : SettingScope.Workspace } ,
2025-07-09 21:16:42 +00:00
{ label : 'System Settings' , value : SettingScope.System } ,
2025-05-01 10:34:07 -07:00
] ;
2025-07-11 18:05:21 -07:00
const handleThemeSelect = useCallback (
( themeName : string ) = > {
onSelect ( themeName , selectedScope ) ;
} ,
[ onSelect , selectedScope ] ,
) ;
2025-05-01 10:34:07 -07:00
2025-07-20 16:51:18 +09:00
const handleThemeHighlight = ( themeName : string ) = > {
setHighlightedThemeName ( themeName ) ;
onHighlight ( themeName ) ;
} ;
2025-07-11 18:05:21 -07:00
const handleScopeHighlight = useCallback ( ( scope : SettingScope ) = > {
2025-05-01 10:34:07 -07:00
setSelectedScope ( scope ) ;
setSelectInputKey ( Date . now ( ) ) ;
2025-07-11 18:05:21 -07:00
} , [ ] ) ;
const handleScopeSelect = useCallback (
( scope : SettingScope ) = > {
handleScopeHighlight ( scope ) ;
setFocusedSection ( 'theme' ) ; // Reset focus to theme section
} ,
[ handleScopeHighlight ] ,
) ;
2025-05-01 10:34:07 -07:00
const [ focusedSection , setFocusedSection ] = useState < 'theme' | 'scope' > (
'theme' ,
2025-04-22 18:57:47 -07:00
) ;
2025-05-01 10:34:07 -07:00
useInput ( ( input , key ) = > {
if ( key . tab ) {
setFocusedSection ( ( prev ) = > ( prev === 'theme' ? 'scope' : 'theme' ) ) ;
}
2025-05-13 07:41:32 -07:00
if ( key . escape ) {
onSelect ( undefined , selectedScope ) ;
}
2025-05-01 10:34:07 -07:00
} ) ;
2025-07-09 21:16:42 +00:00
const otherScopes = Object . values ( SettingScope ) . filter (
( scope ) = > scope !== selectedScope ,
) ;
const modifiedInOtherScopes = otherScopes . filter (
( scope ) = > settings . forScope ( scope ) . settings . theme !== undefined ,
) ;
2025-05-01 10:34:07 -07:00
let otherScopeModifiedMessage = '' ;
2025-07-09 21:16:42 +00:00
if ( modifiedInOtherScopes . length > 0 ) {
const modifiedScopesStr = modifiedInOtherScopes . join ( ', ' ) ;
2025-05-01 10:34:07 -07:00
otherScopeModifiedMessage =
settings . forScope ( selectedScope ) . settings . theme !== undefined
2025-07-09 21:16:42 +00:00
? ` (Also modified in ${ modifiedScopesStr } ) `
: ` (Modified in ${ modifiedScopesStr } ) ` ;
2025-05-01 10:34:07 -07:00
}
2025-06-19 20:17:23 +00:00
// Constants for calculating preview pane layout.
// These values are based on the JSX structure below.
const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55 ;
// A safety margin to prevent text from touching the border.
// This is a complete hack unrelated to the 0.9 used in App.tsx
const PREVIEW_PANE_WIDTH_SAFETY_MARGIN = 0.9 ;
// Combined horizontal padding from the dialog and preview pane.
const TOTAL_HORIZONTAL_PADDING = 4 ;
const colorizeCodeWidth = Math . max (
Math . floor (
( terminalWidth - TOTAL_HORIZONTAL_PADDING ) *
PREVIEW_PANE_WIDTH_PERCENTAGE *
PREVIEW_PANE_WIDTH_SAFETY_MARGIN ,
) ,
1 ,
) ;
2025-07-09 05:46:55 +00:00
const DIALOG_PADDING = 2 ;
2025-06-23 23:43:17 +00:00
const selectThemeHeight = themeItems . length + 1 ;
const SCOPE_SELECTION_HEIGHT = 4 ; // Height for the scope selection section + margin.
const SPACE_BETWEEN_THEME_SELECTION_AND_APPLY_TO = 1 ;
const TAB_TO_SELECT_HEIGHT = 2 ;
availableTerminalHeight = availableTerminalHeight ? ? Number . MAX_SAFE_INTEGER ;
availableTerminalHeight -= 2 ; // Top and bottom borders.
availableTerminalHeight -= TAB_TO_SELECT_HEIGHT ;
let totalLeftHandSideHeight =
2025-07-09 05:46:55 +00:00
DIALOG_PADDING +
2025-06-23 23:43:17 +00:00
selectThemeHeight +
SCOPE_SELECTION_HEIGHT +
SPACE_BETWEEN_THEME_SELECTION_AND_APPLY_TO ;
let showScopeSelection = true ;
let includePadding = true ;
2025-06-26 13:34:53 +09:00
// Remove content from the LHS that can be omitted if it exceeds the available height.
2025-06-23 23:43:17 +00:00
if ( totalLeftHandSideHeight > availableTerminalHeight ) {
includePadding = false ;
2025-07-09 05:46:55 +00:00
totalLeftHandSideHeight -= DIALOG_PADDING ;
2025-06-23 23:43:17 +00:00
}
if ( totalLeftHandSideHeight > availableTerminalHeight ) {
// First, try hiding the scope selection
totalLeftHandSideHeight -= SCOPE_SELECTION_HEIGHT ;
showScopeSelection = false ;
}
// Don't focus the scope selection if it is hidden due to height constraints.
const currenFocusedSection = ! showScopeSelection ? 'theme' : focusedSection ;
2025-06-19 20:17:23 +00:00
// Vertical space taken by elements other than the two code blocks in the preview pane.
2025-06-23 23:43:17 +00:00
// Includes "Preview" title, borders, and margin between blocks.
const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8 ;
// The right column doesn't need to ever be shorter than the left column.
availableTerminalHeight = Math . max (
availableTerminalHeight ,
totalLeftHandSideHeight ,
) ;
const availableTerminalHeightCodeBlock =
availableTerminalHeight -
PREVIEW_PANE_FIXED_VERTICAL_SPACE -
( includePadding ? 2 : 0 ) * 2 ;
2025-07-17 17:46:33 -07:00
// Subtract margin between code blocks from available height.
const availableHeightForPanes = Math . max (
0 ,
availableTerminalHeightCodeBlock - 1 ,
) ;
// The code block is slightly longer than the diff, so give it more space.
const codeBlockHeight = Math . ceil ( availableHeightForPanes * 0.6 ) ;
const diffHeight = Math . floor ( availableHeightForPanes * 0.4 ) ;
2025-04-22 18:57:47 -07:00
return (
< Box
borderStyle = "round"
2025-06-05 14:35:47 -07:00
borderColor = { Colors . Gray }
2025-06-23 23:43:17 +00:00
flexDirection = "column"
paddingTop = { includePadding ? 1 : 0 }
paddingBottom = { includePadding ? 1 : 0 }
paddingLeft = { 1 }
paddingRight = { 1 }
2025-05-08 16:00:55 -07:00
width = "100%"
2025-04-22 18:57:47 -07:00
>
2025-06-23 23:43:17 +00:00
< Box flexDirection = "row" >
{ /* Left Column: Selection */ }
< Box flexDirection = "column" width = "45%" paddingRight = { 2 } >
< Text bold = { currenFocusedSection === 'theme' } wrap = "truncate" >
{ currenFocusedSection === 'theme' ? '> ' : ' ' } Select Theme { ' ' }
< Text color = { Colors . Gray } > { otherScopeModifiedMessage } < / Text >
2025-05-08 16:00:55 -07:00
< / Text >
< RadioButtonSelect
2025-06-23 23:43:17 +00:00
key = { selectInputKey }
items = { themeItems }
2025-07-20 16:51:18 +09:00
initialIndex = { safeInitialThemeIndex }
2025-06-23 23:43:17 +00:00
onSelect = { handleThemeSelect }
2025-07-20 16:51:18 +09:00
onHighlight = { handleThemeHighlight }
2025-06-23 23:43:17 +00:00
isFocused = { currenFocusedSection === 'theme' }
2025-07-11 18:05:21 -07:00
maxItemsToShow = { 8 }
2025-07-12 20:58:00 -07:00
showScrollArrows = { true }
2025-07-17 15:51:42 -07:00
showNumbers = { currenFocusedSection === 'theme' }
2025-05-08 16:00:55 -07:00
/ >
2025-06-23 23:43:17 +00:00
{ /* Scope Selection */ }
{ showScopeSelection && (
< Box marginTop = { 1 } flexDirection = "column" >
< Text bold = { currenFocusedSection === 'scope' } wrap = "truncate" >
{ currenFocusedSection === 'scope' ? '> ' : ' ' } Apply To
< / Text >
< RadioButtonSelect
items = { scopeItems }
initialIndex = { 0 } // Default to User Settings
onSelect = { handleScopeSelect }
onHighlight = { handleScopeHighlight }
isFocused = { currenFocusedSection === 'scope' }
2025-07-17 15:51:42 -07:00
showNumbers = { currenFocusedSection === 'scope' }
2025-06-23 23:43:17 +00:00
/ >
< / Box >
) }
2025-05-08 16:00:55 -07:00
< / Box >
2025-04-24 11:36:34 -07:00
2025-06-23 23:43:17 +00:00
{ /* Right Column: Preview */ }
< Box flexDirection = "column" width = "55%" paddingLeft = { 2 } >
2025-07-20 16:51:18 +09:00
< Text bold > Preview < / Text >
{ /* Get the Theme object for the highlighted theme, fallback to default if not found */ }
{ ( ( ) = > {
const previewTheme =
themeManager . getTheme (
highlightedThemeName || DEFAULT_THEME . name ,
) || DEFAULT_THEME ;
return (
< Box
borderStyle = "single"
borderColor = { Colors . Gray }
paddingTop = { includePadding ? 1 : 0 }
paddingBottom = { includePadding ? 1 : 0 }
paddingLeft = { 1 }
paddingRight = { 1 }
flexDirection = "column"
>
{ colorizeCode (
` # function
- def fibonacci ( n ) :
- a , b = 0 , 1
- for _ in range ( n ) :
- a , b = b , a + b
- return a ` ,
'python' ,
codeBlockHeight ,
colorizeCodeWidth ,
) }
< Box marginTop = { 1 } / >
< DiffRenderer
diffContent = { ` --- a/old_file.txt \ n+++ b/new_file.txt \ n@@ -1,6 +1,7 @@ \ n # function \ n-def fibonacci(n): \ n- a, b = 0, 1 \ n- for _ in range(n): \ n- a, b = b, a + b \ n- return a \ n+def fibonacci(n): \ n+ a, b = 0, 1 \ n+ for _ in range(n): \ n+ a, b = b, a + b \ n+ return a \ n+ \ n+print(fibonacci(10)) \ n ` }
availableTerminalHeight = { diffHeight }
terminalWidth = { colorizeCodeWidth }
theme = { previewTheme }
/ >
< / Box >
) ;
} ) ( ) }
2025-04-24 11:36:34 -07:00
< / Box >
< / Box >
2025-06-23 23:43:17 +00:00
< Box marginTop = { 1 } >
< Text color = { Colors . Gray } wrap = "truncate" >
( Use Enter to select
{ showScopeSelection ? ', Tab to change focus' : '' } )
< / Text >
< / Box >
2025-04-22 18:57:47 -07:00
< / Box >
) ;
}