TerminalGlyphs.psm1
|
function ConvertTo-AnsiSequence { [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [ValidatePattern('^#?[0-9A-Fa-f]{6}$')] [string]$Hex ) $value = $Hex.TrimStart('#') $r = [Convert]::ToInt32($value.Substring(0, 2), 16) $g = [Convert]::ToInt32($value.Substring(2, 2), 16) $b = [Convert]::ToInt32($value.Substring(4, 2), 16) "$([char]27)[38;2;$r;$g;${b}m" } function Find-GlyphEntry { [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$Table, [Parameter(Mandatory)] [ValidateSet('files', 'directories')] [string]$Kind, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Name, [string]$LinkType ) $linkKey = switch ($LinkType) { 'SymbolicLink' { 'symlink' } 'Junction' { 'junction' } default { $null } } if ($linkKey -and $Table.links.ContainsKey($linkKey)) { return [pscustomobject]@{ Entry = $Table.links[$linkKey]; Rule = "$Kind.links[$linkKey]" } } if ($Name -and $Table.names.ContainsKey($Name)) { return [pscustomobject]@{ Entry = $Table.names[$Name]; Rule = "$Kind.names[$Name]" } } if ($Name -and $Table.ContainsKey('extensions')) { # Longest compound suffix first: app.test.d.ts tries .test.d.ts, then .d.ts, then .ts. $dot = $Name.IndexOf('.') while ($dot -ge 0) { $suffix = $Name.Substring($dot) if ($Table.extensions.ContainsKey($suffix)) { return [pscustomobject]@{ Entry = $Table.extensions[$suffix]; Rule = "$Kind.extensions[$suffix]" } } $dot = $Name.IndexOf('.', $dot + 1) } } [pscustomobject]@{ Entry = $Table.default; Rule = "$Kind.default" } } function Find-NerdGlyph { <# .SYNOPSIS Searches the Nerd Fonts 3.5.1 glyph names to use in your TerminalGlyphs config. .PARAMETER Name Part of a glyph name (for example cloudflare) or a wildcard pattern (nf-dev-*). .EXAMPLE Find-NerdGlyph cloudflare .EXAMPLE Find-NerdGlyph 'nf-md-folder_*' .OUTPUTS TerminalGlyphs.NerdGlyph #> [OutputType('TerminalGlyphs.NerdGlyph')] [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [string]$Name ) $pattern = if ([WildcardPattern]::ContainsWildcardCharacters($Name)) { $Name } else { "*$Name*" } $map = Get-FullGlyphMap foreach ($glyphName in ($map.Keys | Where-Object { $_ -like $pattern } | Sort-Object)) { $glyph = $map[$glyphName] [pscustomobject]@{ PSTypeName = 'TerminalGlyphs.NerdGlyph' Name = $glyphName Glyph = $glyph CodePoint = 'U+{0:X4}' -f [char]::ConvertToUtf32($glyph, 0) } } } function Format-TerminalGlyph { <# .SYNOPSIS Prefixes a file or folder name with its Nerd Font icon and color. .DESCRIPTION Used by the TerminalGlyphs view of Get-ChildItem. Resolves the icon and color with the active themes and your config. Never throws: if anything goes wrong, it returns the plain name. .PARAMETER InputObject The file or folder to format. .EXAMPLE Get-ChildItem TerminalGlyphs formats every item automatically. .EXAMPLE Get-Item ./go.mod | Format-TerminalGlyph .INPUTS System.IO.FileSystemInfo .OUTPUTS System.String #> [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [System.IO.FileSystemInfo]$InputObject ) process { try { Initialize-TerminalGlyph $resolved = Resolve-TerminalGlyph -Name $InputObject.Name -Directory:($InputObject -is [System.IO.DirectoryInfo]) -LinkType ([string]$InputObject.LinkType) $text = if ($resolved.Icon) { "$($resolved.Icon) $($InputObject.Name)" } else { $InputObject.Name } if ($InputObject.LinkTarget) { $text = "$text $($script:TGState.Arrow) $($InputObject.LinkTarget)" } if ($resolved.Color -and $PSStyle.OutputRendering -ne 'PlainText') { "$($resolved.Color)$text$($PSStyle.Reset)" } else { $text } } catch { $InputObject.Name } } } function Get-ConfigPath { [OutputType([string])] [CmdletBinding()] param() if ($env:TERMINALGLYPHS_CONFIG) { return $env:TERMINALGLYPHS_CONFIG } $base = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { [System.IO.Path]::Combine([Environment]::GetFolderPath('UserProfile'), '.config') } [System.IO.Path]::Combine($base, 'terminalglyphs', 'config.jsonc') } function Get-FontLocation { <# .SYNOPSIS Returns the platform, the per-user font folder and the font folders for all users. .DESCRIPTION Directory is where fonts are installed. SystemDirectory lists the folders whose Nerd Fonts are only reported, because changing them needs admin rights. Nothing is read or created here. #> [OutputType([pscustomobject])] [CmdletBinding()] param() $userProfile = [Environment]::GetFolderPath('UserProfile') if ($IsWindows) { $platform = 'Windows' $directory = [System.IO.Path]::Combine([Environment]::GetFolderPath('LocalApplicationData'), 'Microsoft', 'Windows', 'Fonts') $systemDirectory = [string[]]@([Environment]::GetFolderPath('Fonts')) } elseif ($IsMacOS) { $platform = 'MacOS' $directory = [System.IO.Path]::Combine($userProfile, 'Library', 'Fonts') $systemDirectory = [string[]]@('/Library/Fonts') } else { $platform = 'Linux' $dataHome = if ($env:XDG_DATA_HOME) { $env:XDG_DATA_HOME } else { [System.IO.Path]::Combine($userProfile, '.local', 'share') } $directory = [System.IO.Path]::Combine($dataHome, 'fonts') $systemDirectory = [string[]]@('/usr/share/fonts', '/usr/local/share/fonts', [System.IO.Path]::Combine($userProfile, '.fonts')) } [pscustomobject]@{ Platform = $platform; Directory = $directory; SystemDirectory = $systemDirectory } } function Get-FullGlyphMap { [OutputType([System.Collections.IDictionary])] [CmdletBinding()] param() if ($null -eq $script:FullGlyphs) { try { $script:FullGlyphs = Read-JsoncFile -Path $script:GlyphsPath } catch { Write-GlyphWarning -Message "could not load '$($script:GlyphsPath)': $($_.Exception.Message)" $script:FullGlyphs = @{} } } $script:FullGlyphs } function Get-GlyphThemeEntry { [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [System.Collections.IDictionary]$Theme ) foreach ($kind in @($Theme.Keys)) { if ($kind -ceq 'name' -or $kind -ceq '$schema') { continue } $kindValue = $Theme[$kind] if ($kindValue -isnot [System.Collections.IDictionary]) { [pscustomobject]@{ Kind = $kind; Section = $null; Key = $null; Value = $kindValue } continue } foreach ($section in @($kindValue.Keys)) { $sectionValue = $kindValue[$section] if ($sectionValue -is [System.Collections.IDictionary]) { foreach ($key in @($sectionValue.Keys)) { [pscustomobject]@{ Kind = $kind; Section = $section; Key = $key; Value = $sectionValue[$key] } } } else { [pscustomobject]@{ Kind = $kind; Section = $section; Key = $null; Value = $sectionValue } } } } } function Get-NerdFontInstallation { <# .SYNOPSIS Groups the Nerd Font files in a folder by release package and reports which ones are outdated. .DESCRIPTION The package of a file is found from the part of its name before "NerdFont", using the longest matching prefix in -PackageMap (JetBrainsMonoNL and JetBrainsMono both belong to JetBrainsMono). Files without a readable Nerd Fonts version count as outdated. Nerd Fonts 2.x files (named like "Hack Regular Nerd Font Complete.ttf") are reported together as one outdated 'Nerd Fonts 2.x' object with IsLegacy set. #> [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$FontDirectory, [Parameter(Mandatory)] [System.Collections.IDictionary]$PackageMap, [Parameter(Mandatory)] [version]$MinimumVersion ) if (-not [System.IO.Directory]::Exists($FontDirectory)) { return } $ignoreCase = [System.StringComparison]::OrdinalIgnoreCase $prefixes = @($PackageMap.Keys | Sort-Object -Property Length -Descending) $families = [ordered]@{} $fonts = @(Get-ChildItem -LiteralPath $FontDirectory -Recurse -File -ErrorAction Ignore | Where-Object { $_.Extension -in '.ttf', '.otf' } | Sort-Object -Property FullName) $files = $fonts | Where-Object { $_.Name.Contains('NerdFont', $ignoreCase) } # Nerd Fonts 2.x names files like "Hack Regular Nerd Font Complete.ttf". $legacyFiles = @($fonts | Where-Object { $_.Name.Contains(' Nerd Font ', $ignoreCase) -and -not $_.Name.Contains('NerdFont', $ignoreCase) }) foreach ($file in $files) { $stem = $file.Name.Substring(0, $file.Name.IndexOf('NerdFont', $ignoreCase)) $prefix = $prefixes | Where-Object { $stem.StartsWith($_, $ignoreCase) } | Select-Object -First 1 $package = if ($prefix) { $PackageMap[$prefix] } else { $null } $key = if ($package) { $package } else { "?$stem" } if (-not $families.Contains($key)) { $families[$key] = [pscustomobject]@{ PSTypeName = 'TerminalGlyphs.NerdFontFamily' Name = if ($package) { $package } else { $stem } Package = $package Files = [System.Collections.Generic.List[string]]::new() Version = $null IsOutdated = $false IsLegacy = $false } } $entry = $families[$key] $entry.Files.Add($file.FullName) $info = Read-FontInfo -Path $file.FullName if ($null -eq $info -or $null -eq $info.Version) { $entry.IsOutdated = $true continue } if ($null -eq $entry.Version -or $info.Version -lt $entry.Version) { $entry.Version = $info.Version } if ($info.Version -lt $MinimumVersion) { $entry.IsOutdated = $true } } $families.Values if ($legacyFiles.Count -gt 0) { $legacyVersion = $null foreach ($file in $legacyFiles) { $info = Read-FontInfo -Path $file.FullName if ($null -ne $info -and $null -ne $info.Version -and ($null -eq $legacyVersion -or $info.Version -lt $legacyVersion)) { $legacyVersion = $info.Version } } [pscustomobject]@{ PSTypeName = 'TerminalGlyphs.NerdFontFamily' Name = 'Nerd Fonts 2.x' Package = $null Files = [System.Collections.Generic.List[string]]@($legacyFiles.FullName) Version = $legacyVersion IsOutdated = $true IsLegacy = $true } } } function Get-TerminalGlyph { <# .SYNOPSIS Shows which icon and color TerminalGlyphs uses for a file or folder, and why. .DESCRIPTION Resolves the icon and color with the active themes and your config, and reports the rule that matched (for example files.names[go.mod]) and where it came from (theme:<name> or user-config). .PARAMETER Path Path to a file or folder. Supports wildcards. .PARAMETER LiteralPath Path used exactly as typed. Objects from Get-ChildItem bind here through PSPath. .EXAMPLE Get-TerminalGlyph ./go.mod .EXAMPLE Get-ChildItem | Get-TerminalGlyph .OUTPUTS TerminalGlyphs.GlyphInfo #> [OutputType('TerminalGlyphs.GlyphInfo')] [CmdletBinding(DefaultParameterSetName = 'Path')] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline, ParameterSetName = 'Path')] [string[]]$Path, [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'LiteralPath')] [Alias('PSPath')] [string[]]$LiteralPath ) begin { Initialize-TerminalGlyph } process { $items = if ($PSCmdlet.ParameterSetName -eq 'Path') { Get-Item -Path $Path -Force } else { Get-Item -LiteralPath $LiteralPath -Force } foreach ($item in $items) { if ($item -isnot [System.IO.FileSystemInfo]) { continue } $resolved = Resolve-TerminalGlyph -Name $item.Name -Directory:($item -is [System.IO.DirectoryInfo]) -LinkType ([string]$item.LinkType) [pscustomobject]@{ PSTypeName = 'TerminalGlyphs.GlyphInfo' Name = $item.Name Icon = $resolved.Icon IconName = $resolved.IconName Color = $resolved.ColorName Rule = $resolved.Rule Source = $resolved.Source } } } } function Initialize-TerminalGlyph { [CmdletBinding()] param( [switch]$Force ) if ($script:TGState -and -not $Force) { return } try { $data = Read-JsoncFile -Path $script:DataPath $builtinGlyphs = $data['glyphs'] $configPath = Get-ConfigPath $config = Read-UserConfig -Path $configPath $requestedIcon = if ($config) { $config['iconTheme'] } else { $null } $requestedColor = if ($config) { $config['colorTheme'] } else { $null } $iconThemeName = Select-GlyphThemeName -Requested $requestedIcon -Available $data['iconThemes'] -Setting 'iconTheme' -ConfigPath $configPath $colorThemeName = Select-GlyphThemeName -Requested $requestedColor -Available $data['colorThemes'] -Setting 'colorTheme' -ConfigPath $configPath # These script blocks run inside Merge-GlyphConfig and see these variables through dynamic scoping. $glyphExists = { param($name) $name -is [string] -and ($builtinGlyphs.Contains($name) -or (Get-FullGlyphMap).Contains($name)) } $resolveIcon = { param($name) if ($builtinGlyphs.Contains($name)) { $builtinGlyphs[$name] } else { (Get-FullGlyphMap)[$name] } } $ansiCache = @{} $resolveColor = { param($hex) $key = $hex.TrimStart('#').ToUpperInvariant() if (-not $ansiCache.ContainsKey($key)) { $ansiCache[$key] = ConvertTo-AnsiSequence -Hex $key } $ansiCache[$key] } $icons = New-GlyphTable $colors = New-GlyphTable Merge-GlyphConfig -Table $icons -Theme $data['iconThemes'][$iconThemeName] -ThemeType Icon -Source "theme:$iconThemeName" -Resolve $resolveIcon Merge-GlyphConfig -Table $colors -Theme $data['colorThemes'][$colorThemeName] -ThemeType Color -Source "theme:$colorThemeName" -Resolve $resolveColor if ($config) { $layers = @( @{ Setting = 'icons'; Table = $icons; Type = 'Icon'; Resolve = $resolveIcon } @{ Setting = 'colors'; Table = $colors; Type = 'Color'; Resolve = $resolveColor } ) foreach ($layer in $layers) { $section = $config[$layer.Setting] if ($null -eq $section) { continue } if ($section -isnot [System.Collections.IDictionary]) { Write-GlyphWarning -Message "$($configPath): '$($layer.Setting)' must be an object, ignoring it." continue } Merge-GlyphConfig -Table $layer.Table -Theme $section -ThemeType $layer.Type -Source 'user-config' -Origin $configPath -Validate -GlyphExists $glyphExists -Resolve $layer.Resolve } } $arrow = if ($builtinGlyphs.Contains('nf-md-arrow_right_thick')) { $builtinGlyphs['nf-md-arrow_right_thick'] } else { '->' } $script:TGState = @{ Icons = $icons Colors = $colors Arrow = $arrow IconTheme = $iconThemeName ColorTheme = $colorThemeName ConfigPath = $configPath } } catch { Write-GlyphWarning -Message "could not initialize, showing names without icons: $($_.Exception.Message)" $script:TGState = @{ Icons = New-GlyphTable Colors = New-GlyphTable Arrow = '->' IconTheme = $null ColorTheme = $null ConfigPath = $null } } } function Install-NerdFontFile { <# .SYNOPSIS Copies Nerd Font files into the user's font folder, replacing the installed copies. .DESCRIPTION Each source file replaces the installed file with the same name (-InstalledFile), wherever it is. Files that are not installed are skipped with -UpdateOnly; otherwise they are copied to -TargetDirectory and, on Windows, registered for the current user and loaded. On Windows, an installed file that is in use is renamed to <name>.<yyyyMMddHHmmss>.old-nerdfont (UTC) before copying; Remove-StaleFontFile deletes it after a restart. #> [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [string[]]$SourceFile, [Parameter(Mandatory)] [string]$TargetDirectory, [Parameter(Mandatory)] [ValidateSet('Windows', 'Linux', 'MacOS')] [string]$Platform, [string[]]$InstalledFile = @(), [switch]$UpdateOnly, [string]$RegistryPath = 'HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Fonts' ) # This function runs only after Install-TerminalGlyphSetup approved the package, so the cmdlets below must not # ask again when the caller passes -Confirm or -WhatIf. $installed = @{} foreach ($path in $InstalledFile) { $installed[[System.IO.Path]::GetFileName($path)] = $path } $result = [pscustomobject]@{ Added = [System.Collections.Generic.List[string]]::new() Replaced = 0 Renamed = 0 Failed = [System.Collections.Generic.List[string]]::new() } foreach ($source in $SourceFile) { $name = [System.IO.Path]::GetFileName($source) if ($installed.ContainsKey($name)) { $target = $installed[$name] try { Copy-Item -LiteralPath $source -Destination $target -Force -Confirm:$false -WhatIf:$false -ErrorAction Stop $result.Replaced++ continue } catch { if ($Platform -ne 'Windows') { $result.Failed.Add($name) continue } } # Windows keeps fonts in use open: it allows renaming them but not overwriting them. $stale = '{0}.{1}.old-nerdfont' -f $target, [DateTime]::UtcNow.ToString('yyyyMMddHHmmss', [cultureinfo]::InvariantCulture) try { Move-Item -LiteralPath $target -Destination $stale -Confirm:$false -WhatIf:$false -ErrorAction Stop } catch { $result.Failed.Add($name) continue } try { Copy-Item -LiteralPath $source -Destination $target -Confirm:$false -WhatIf:$false -ErrorAction Stop $result.Renamed++ } catch { $result.Failed.Add($name) try { Move-Item -LiteralPath $stale -Destination $target -Force -Confirm:$false -WhatIf:$false -ErrorAction Stop } catch { Write-Warning -Message "TerminalGlyphs: $name could not be restored now; it will be restored the next time you run Install-TerminalGlyphSetup." } } continue } if ($UpdateOnly) { continue } [System.IO.Directory]::CreateDirectory($TargetDirectory) | Out-Null $target = [System.IO.Path]::Combine($TargetDirectory, $name) try { Copy-Item -LiteralPath $source -Destination $target -Force -Confirm:$false -WhatIf:$false -ErrorAction Stop } catch { $result.Failed.Add($name) continue } if ($Platform -eq 'Windows') { $info = Read-FontInfo -Path $target $label = if ($info) { $info.FullName } else { [System.IO.Path]::GetFileNameWithoutExtension($target) } $kind = if ([System.IO.Path]::GetExtension($target) -eq '.otf') { 'OpenType' } else { 'TrueType' } if (-not (Test-Path -LiteralPath $RegistryPath)) { New-Item -Path $RegistryPath -Force -Confirm:$false -WhatIf:$false | Out-Null } New-ItemProperty -LiteralPath $RegistryPath -Name "$label ($kind)" -Value $target -PropertyType String -Force -Confirm:$false -WhatIf:$false | Out-Null } $result.Added.Add($target) } if ($Platform -eq 'Windows' -and $result.Added.Count -gt 0) { Register-FontResource -Path $result.Added.ToArray() } $result } function Install-TerminalGlyphSetup { <# .SYNOPSIS Installs or updates Nerd Fonts and makes your PowerShell profile import TerminalGlyphs. .DESCRIPTION Updates the Nerd Fonts in your user font folder that are older than the version TerminalGlyphs is built for (3.5.1), installs the packages in -Family (or JetBrainsMono if you have no Nerd Font), and replaces "Import-Module Terminal-Icons" in your profile, keeping a backup. Fonts are installed for the current user only, without admin rights. Your terminal settings are not changed: choose the font there afterwards. Nerd Fonts 2.x files are not changed: remove them first, then run the command again. Nerd Fonts installed for all users are reported but not changed. Each step runs even if another one fails, and the command returns one result per step. On Windows, fonts that were in use are replaced after you restart Windows. .PARAMETER Family Nerd Fonts release packages to install or update, such as JetBrainsMono, FiraCode, CascadiaCode or Meslo. .PARAMETER SkipFont Does not install, update or clean up fonts. .PARAMETER SkipProfile Does not change your profile. .EXAMPLE Install-TerminalGlyphSetup .EXAMPLE Install-TerminalGlyphSetup -Family FiraCode -WhatIf #> [OutputType([pscustomobject])] [CmdletBinding(SupportsShouldProcess)] param( [string[]]$Family, [switch]$SkipFont, [switch]$SkipProfile ) $ErrorActionPreference = 'Stop' $nerdFonts = [System.IO.File]::ReadAllText($script:FontsPath) | ConvertFrom-Json -AsHashtable $version = [version]$nerdFonts['version'] $packageMap = $nerdFonts['packages'] $knownPackages = @($packageMap.Values | Sort-Object -Unique) $requested = foreach ($name in $Family) { $match = $knownPackages | Where-Object { $_ -eq $name } | Select-Object -First 1 if (-not $match) { throw "Unknown Nerd Fonts package '$name'. Use a release package name such as JetBrainsMono, FiraCode, CascadiaCode, Hack or Meslo." } $match } $newStep = { param([string]$Name, [string]$Status, [string]$Detail) [pscustomobject]@{ PSTypeName = 'TerminalGlyphs.SetupStep'; Step = $Name; Status = $Status; Detail = $Detail } } $restartNeeded = $false $profileChanged = $false $fontToChoose = $null if ($SkipFont) { & $newStep 'Fonts' 'Skipped' 'Skipped with -SkipFont' } else { $work = $null try { $location = Get-FontLocation if ($location.Platform -eq 'Windows') { try { $stale = Remove-StaleFontFile -FontDirectory $location.Directory if ($stale.Pending -gt 0) { $restartNeeded = $true } if ($stale.Removed + $stale.Restored -gt 0) { & $newStep 'Font cleanup' 'OK' ('Removed {0} replaced file(s), restored {1}' -f $stale.Removed, $stale.Restored) } elseif ($stale.Pending -gt 0) { & $newStep 'Font cleanup' 'Unchanged' ('{0} replaced file(s) waiting for a restart' -f $stale.Pending) } else { & $newStep 'Font cleanup' 'Unchanged' 'Nothing to clean up' } } catch { & $newStep 'Font cleanup' 'Error' $_.Exception.Message } } $installed = @(Get-NerdFontInstallation -FontDirectory $location.Directory -PackageMap $packageMap -MinimumVersion $version) # Fonts installed for all users are only reported: changing them needs admin rights. $system = @(foreach ($systemDirectory in @($location.SystemDirectory | Where-Object { $_ })) { Get-NerdFontInstallation -FontDirectory $systemDirectory -PackageMap $packageMap -MinimumVersion $version }) foreach ($scan in @(@{ Suffix = ''; Found = $installed }, @{ Suffix = ' (all users)'; Found = $system })) { foreach ($legacy in ($scan.Found | Where-Object { $_.IsLegacy })) { $advice = '{0} file(s) from Nerd Fonts 2.x; remove them in your system font settings, then run Install-TerminalGlyphSetup again' -f $legacy.Files.Count Write-Warning -Message "TerminalGlyphs: $advice." & $newStep "Font $($legacy.Name)$($scan.Suffix)" 'Skipped' $advice } } foreach ($unknown in ($installed | Where-Object { -not $_.Package -and -not $_.IsLegacy })) { Write-Warning -Message "TerminalGlyphs: $($unknown.Name) is not a Nerd Fonts $version family; it was not changed." & $newStep "Font $($unknown.Name)" 'Skipped' 'Unknown Nerd Fonts family' } foreach ($shared in ($system | Where-Object { -not $_.IsLegacy })) { $stepName = "Font $($shared.Name) (all users)" if ($shared.IsOutdated) { $from = if ($shared.Version) { $shared.Version } else { 'unknown version' } & $newStep $stepName 'Skipped' "Nerd Fonts $from installed for all users; updating it needs admin rights" } else { & $newStep $stepName 'Unchanged' "Nerd Fonts $($shared.Version)" } } $plan = [ordered]@{} foreach ($entry in ($installed | Where-Object { $_.Package })) { $plan[$entry.Package] = $entry } foreach ($name in $requested) { if (-not $plan.Contains($name)) { $plan[$name] = $null } } if (-not $Family -and $installed.Count -eq 0 -and $system.Count -eq 0) { $plan['JetBrainsMono'] = $null } $changed = 0 foreach ($package in $plan.Keys) { $current = $plan[$package] $stepName = "Font $package" if ($current -and -not $current.IsOutdated) { & $newStep $stepName 'Unchanged' "Nerd Fonts $($current.Version)" continue } $action = if ($current) { $from = if ($current.Version) { $current.Version } else { 'an unknown version' } "Update from $from to Nerd Fonts $version" } else { "Install Nerd Fonts $version" } if (-not $PSCmdlet.ShouldProcess("$package Nerd Font", $action)) { & $newStep $stepName 'Skipped' $action continue } try { if (-not $work) { $work = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "terminalglyphs-$([guid]::NewGuid())") [System.IO.Directory]::CreateDirectory($work) | Out-Null } $files = @(Save-NerdFontRelease -Package $package -Version $version.ToString() -Destination $work) $target = if ($location.Platform -eq 'Linux') { [System.IO.Path]::Combine($location.Directory, 'NerdFonts', $package) } else { $location.Directory } $installedFiles = @() if ($current) { $installedFiles = [string[]]$current.Files } $result = Install-NerdFontFile -SourceFile $files -TargetDirectory $target -Platform $location.Platform -InstalledFile $installedFiles -UpdateOnly:([bool]$current) $fileChanges = $result.Added.Count + $result.Replaced + $result.Renamed $changed += $fileChanges if ($result.Renamed -gt 0) { $restartNeeded = $true } if (-not $current -and -not $fontToChoose -and $result.Added.Count -gt 0) { $mono = $result.Added | Where-Object { [System.IO.Path]::GetFileName($_) -like '*NerdFontMono-Regular.*' } | Select-Object -First 1 $info = if ($mono) { Read-FontInfo -Path $mono } $fontToChoose = if ($info -and $info.Family) { $info.Family } else { "$package Nerd Font Mono" } } $detail = '{0}: {1} added, {2} replaced' -f $action, $result.Added.Count, ($result.Replaced + $result.Renamed) if ($result.Failed.Count -gt 0) { $problem = if ($location.Platform -eq 'Windows') { 'in use could not be replaced, close the apps that use them and run again' } else { 'could not be replaced (check permissions)' } & $newStep $stepName 'Error' ('{0}; {1} file(s) {2}' -f $detail, $result.Failed.Count, $problem) } elseif ($current -and $fileChanges -eq 0) { & $newStep $stepName 'Error' ('{0}: none of the installed files are in the {1} package' -f $action, $package) } else { & $newStep $stepName 'OK' $detail } } catch { & $newStep $stepName 'Error' $_.Exception.Message } } if ($changed -gt 0 -and $location.Platform -eq 'Linux' -and -not (Invoke-FontCacheRefresh -Directory $location.Directory)) { Write-Warning -Message 'TerminalGlyphs: fc-cache was not found or failed; sign out and back in to see the new fonts.' } } catch { & $newStep 'Fonts' 'Error' $_.Exception.Message } finally { if ($work) { Remove-Item -LiteralPath $work -Recurse -Force -WhatIf:$false -Confirm:$false -ErrorAction Ignore } } } if ($SkipProfile) { & $newStep 'Profile' 'Skipped' 'Skipped with -SkipProfile' } else { try { $profileResult = Update-ProfileImport & $newStep 'Profile' $profileResult.Status ('{0}: {1}' -f $profileResult.Path, $profileResult.Detail) $profileChanged = $profileResult.Status -eq 'OK' } catch { & $newStep 'Profile' 'Error' $_.Exception.Message } } if ($fontToChoose) { Write-Host "Set your terminal font to '$fontToChoose'." } if ($restartNeeded) { Write-Host 'Restart Windows to finish replacing fonts that were in use; until then, apps keep the old version. Then run Install-TerminalGlyphSetup again to remove the replaced files.' } if ($profileChanged) { Write-Host 'Open a new terminal to load TerminalGlyphs.' } } function Invoke-FontCacheRefresh { # Rebuilds the fontconfig cache on Linux. Returns $false when fc-cache is not installed or fails. [OutputType([bool])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Directory ) $fcCache = Get-Command -Name 'fc-cache' -CommandType Application -ErrorAction Ignore | Select-Object -First 1 if (-not $fcCache) { return $false } & $fcCache.Source -f $Directory | Out-Null $LASTEXITCODE -eq 0 } function Invoke-NerdFontDownload { # The only network access of the module; tests replace it. [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Uri, [Parameter(Mandatory)] [string]$OutFile ) Invoke-WebRequest -Uri $Uri -OutFile $OutFile -ErrorAction Stop } function Merge-GlyphConfig { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$Table, [AllowNull()] [System.Collections.IDictionary]$Theme, [Parameter(Mandatory)] [ValidateSet('Icon', 'Color')] [string]$ThemeType, [Parameter(Mandatory)] [string]$Source, [Parameter(Mandatory)] [scriptblock]$Resolve, [scriptblock]$GlyphExists = { $true }, [string]$Origin = $Source, [switch]$Validate ) if ($null -eq $Theme) { return } foreach ($entry in (Get-GlyphThemeEntry -Theme $Theme)) { if ($Validate) { $problem = Test-GlyphThemeEntry -Entry $entry -ThemeType $ThemeType -GlyphExists $GlyphExists if ($problem) { Write-GlyphWarning -Message "$($Origin): ignoring $problem" continue } } $target = $Table[$entry.Kind] if ($null -eq $target -or $null -eq $entry.Section) { continue } $resolved = & $Resolve $entry.Value if ($null -eq $resolved) { continue } $name = $entry.Value if ($ThemeType -eq 'Color' -and $name -is [string]) { $name = $name.TrimStart('#').ToUpperInvariant() } $item = [pscustomobject]@{ Value = $resolved; Name = $name; Source = $Source } if ($entry.Section -ceq 'default') { $target['default'] = $item } elseif ($null -ne $entry.Key -and $target.ContainsKey($entry.Section)) { $target[$entry.Section][$entry.Key] = $item } } } function New-GlyphTable { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an in-memory table only.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseLiteralInitializerForHashtable', '', Justification = 'The tables need an explicit OrdinalIgnoreCase comparer, which a literal initializer cannot provide.')] [OutputType([hashtable])] [CmdletBinding()] param() $comparer = [System.StringComparer]::OrdinalIgnoreCase @{ files = @{ names = [hashtable]::new($comparer) extensions = [hashtable]::new($comparer) links = [hashtable]::new($comparer) default = $null } directories = @{ names = [hashtable]::new($comparer) links = [hashtable]::new($comparer) default = $null } } } function Read-FontInfo { <# .SYNOPSIS Reads the full name, family and Nerd Fonts version from the OpenType 'name' table of a font file. .DESCRIPTION Returns $null when the file cannot be read or has no full name. Version is $null when the version string has no "Nerd Fonts X.Y.Z" part. US English Windows records are preferred over other languages. #> [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path ) $stream = $null try { $stream = [System.IO.File]::OpenRead($Path) $read = { param([long]$Offset, [long]$Count) if ($Count -gt 1MB) { throw 'The name table is too large.' } $buffer = [byte[]]::new($Count) [void]$stream.Seek($Offset, [System.IO.SeekOrigin]::Begin) $stream.ReadExactly($buffer, 0, $buffer.Length) , $buffer } $u16 = { param([byte[]]$Bytes, [int]$At) ([int]$Bytes[$At] -shl 8) -bor $Bytes[$At + 1] } $u32 = { param([byte[]]$Bytes, [int]$At) ([long]$Bytes[$At] -shl 24) -bor ([long]$Bytes[$At + 1] -shl 16) -bor ([long]$Bytes[$At + 2] -shl 8) -bor $Bytes[$At + 3] } $header = & $read 0 12 $tableCount = & $u16 $header 4 $directory = & $read 12 (16 * $tableCount) $table = $null for ($i = 0; $i -lt $tableCount; $i++) { if ([System.Text.Encoding]::ASCII.GetString($directory, 16 * $i, 4) -ceq 'name') { $table = & $read (& $u32 $directory (16 * $i + 8)) (& $u32 $directory (16 * $i + 12)) break } } if ($null -eq $table) { return $null } $count = & $u16 $table 2 $storage = & $u16 $table 4 $names = @{} $ranks = @{} for ($i = 0; $i -lt $count; $i++) { $record = 6 + 12 * $i $platform = & $u16 $table $record $language = & $u16 $table ($record + 4) $nameId = & $u16 $table ($record + 6) if ($platform -notin 0, 3 -or $nameId -notin 1, 4, 5, 16) { continue } $rank = if ($platform -eq 3 -and $language -eq 0x409) { 0 } else { 1 } if ($ranks.ContainsKey($nameId) -and $ranks[$nameId] -le $rank) { continue } $ranks[$nameId] = $rank $names[$nameId] = [System.Text.Encoding]::BigEndianUnicode.GetString($table, $storage + (& $u16 $table ($record + 10)), (& $u16 $table ($record + 8))) } if (-not $names.ContainsKey(4)) { return $null } $version = $null if ($names[5] -match 'Nerd Fonts (\d+\.\d+(?:\.\d+)?)') { $version = [version]$Matches[1] } $family = if ($names.ContainsKey(16)) { $names[16] } else { $names[1] } [pscustomobject]@{ FullName = $names[4]; Family = $family; Version = $version } } catch { Write-Verbose -Message "Could not read font names from ${Path}: $($_.Exception.Message)" $null } finally { if ($stream) { $stream.Dispose() } } } function Read-JsoncFile { [OutputType([System.Collections.IDictionary])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path ) $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) $text = [System.IO.File]::ReadAllText($fullPath) if ([string]::IsNullOrWhiteSpace($text)) { throw "'$fullPath' is empty." } $value = ConvertFrom-Json -InputObject $text -AsHashtable -Depth 32 -ErrorAction Stop if ($value -isnot [System.Collections.IDictionary]) { throw "'$fullPath' must contain a JSON object at the root." } $value } function Read-UserConfig { [OutputType([System.Collections.IDictionary])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path ) $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) if (-not [System.IO.File]::Exists($fullPath)) { return $null } try { Read-JsoncFile -Path $fullPath } catch { Write-GlyphWarning -Message "could not read config '$fullPath', using the built-in theme: $($_.Exception.Message)" $null } } function Register-FontResource { # Loads newly copied fonts into the current Windows session, so they can be used without signing out. [CmdletBinding()] param( [Parameter(Mandatory)] [string[]]$Path ) $api = 'TerminalGlyphs.FontApi' -as [type] if (-not $api) { Add-Type -Namespace 'TerminalGlyphs' -Name 'FontApi' -MemberDefinition @' [DllImport("gdi32.dll", CharSet = CharSet.Unicode)] public static extern int AddFontResourceW(string lpFileName); [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern IntPtr SendMessageTimeoutW(IntPtr hWnd, uint Msg, UIntPtr wParam, IntPtr lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult); '@ $api = 'TerminalGlyphs.FontApi' -as [type] } foreach ($file in $Path) { if ($api::AddFontResourceW($file) -eq 0) { throw "Windows could not load the font $file." } } $result = [UIntPtr]::Zero # HWND_BROADCAST, WM_FONTCHANGE, SMTO_ABORTIFHUNG, 1 second. [void]$api::SendMessageTimeoutW([IntPtr]0xFFFF, 0x001D, [UIntPtr]::Zero, [IntPtr]::Zero, 0x0002, 1000, [ref]$result) } function Remove-StaleFontFile { <# .SYNOPSIS Deletes font files renamed by Install-NerdFontFile once Windows has restarted. .DESCRIPTION The Windows font cache keeps using a renamed font file until the next restart; deleting it earlier makes apps fall back to other fonts. A renamed file whose original is missing is restored instead. #> [OutputType([pscustomobject])] [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string]$FontDirectory, [datetime]$BootTime = (Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime ) $result = [pscustomobject]@{ Removed = 0; Restored = 0; Pending = 0 } if (-not [System.IO.Directory]::Exists($FontDirectory)) { return $result } $boot = $BootTime.ToUniversalTime() $styles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal $files = Get-ChildItem -LiteralPath $FontDirectory -Filter '*.old-nerdfont' -File -ErrorAction Ignore | Sort-Object -Property Name -Descending foreach ($file in $files) { if ($file.Name -notmatch '^(?<original>.+)\.(?<stamp>\d{14})\.old-nerdfont$') { continue } $originalName = $Matches['original'] $renamedAt = [datetime]::MinValue if (-not [datetime]::TryParseExact($Matches['stamp'], 'yyyyMMddHHmmss', [cultureinfo]::InvariantCulture, $styles, [ref]$renamedAt)) { continue } $original = [System.IO.Path]::Combine($file.DirectoryName, $originalName) if (-not [System.IO.File]::Exists($original)) { if ($PSCmdlet.ShouldProcess($file.FullName, "Restore missing font $originalName")) { try { Move-Item -LiteralPath $file.FullName -Destination $original -Confirm:$false -WhatIf:$false -ErrorAction Stop $result.Restored++ } catch { $result.Pending++ } } continue } if ($boot -le $renamedAt) { $result.Pending++ continue } if ($PSCmdlet.ShouldProcess($file.FullName, 'Delete replaced font file')) { try { Remove-Item -LiteralPath $file.FullName -Force -Confirm:$false -WhatIf:$false -ErrorAction Stop $result.Removed++ } catch { $result.Pending++ } } } $result } function Resolve-TerminalGlyph { [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Name, [switch]$Directory, [string]$LinkType ) $kind = if ($Directory) { 'directories' } else { 'files' } $icon = Find-GlyphEntry -Table $script:TGState.Icons[$kind] -Kind $kind -Name $Name -LinkType $LinkType $color = Find-GlyphEntry -Table $script:TGState.Colors[$kind] -Kind $kind -Name $Name -LinkType $LinkType [pscustomobject]@{ Icon = $icon.Entry.Value IconName = $icon.Entry.Name Color = $color.Entry.Value ColorName = $color.Entry.Name Rule = $icon.Rule Source = $icon.Entry.Source } } function Save-NerdFontRelease { <# .SYNOPSIS Downloads a Nerd Fonts release package, checks its SHA-256 and extracts its font files. #> [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Package, [Parameter(Mandatory)] [string]$Version, [Parameter(Mandatory)] [string]$Destination, [string]$BaseUri = 'https://github.com/ryanoasis/nerd-fonts/releases/download' ) $release = "$BaseUri/v$Version" $archiveName = "$Package.tar.xz" $sums = [System.IO.Path]::Combine($Destination, 'SHA-256.txt') if (-not [System.IO.File]::Exists($sums)) { Invoke-NerdFontDownload -Uri "$release/SHA-256.txt" -OutFile $sums } $expected = $null foreach ($line in [System.IO.File]::ReadAllLines($sums)) { if ($line -match '^([0-9a-fA-F]{64})\s+\*?(.+?)\s*$' -and $Matches[2] -ceq $archiveName) { $expected = $Matches[1] break } } if (-not $expected) { throw "SHA-256.txt of Nerd Fonts $Version has no entry for $archiveName." } $archive = [System.IO.Path]::Combine($Destination, $archiveName) Invoke-NerdFontDownload -Uri "$release/$archiveName" -OutFile $archive $actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash if ($actual -ne $expected) { throw "Checksum mismatch for ${archiveName}: expected $expected, got $actual." } $extract = [System.IO.Path]::Combine($Destination, $Package) [System.IO.Directory]::CreateDirectory($extract) | Out-Null $tar = if ($IsWindows) { [System.IO.Path]::Combine($env:SystemRoot, 'System32', 'tar.exe') } else { 'tar' } & $tar -xf $archive -C $extract if ($LASTEXITCODE -ne 0) { throw "tar could not extract $archiveName (exit code $LASTEXITCODE)." } Get-ChildItem -LiteralPath $extract -Recurse -File | Where-Object { $_.Extension -in '.ttf', '.otf' } | Sort-Object -Property Name | ForEach-Object { $_.FullName } } function Select-GlyphThemeName { [OutputType([string])] [CmdletBinding()] param( [AllowNull()] [object]$Requested, [Parameter(Mandatory)] [System.Collections.IDictionary]$Available, [Parameter(Mandatory)] [string]$Setting, [Parameter(Mandatory)] [string]$ConfigPath ) if ($null -eq $Requested) { return 'default' } if ($Requested -is [string]) { $match = @($Available.Keys | Where-Object { $_ -eq $Requested }) if ($match.Count -gt 0) { return [string]$match[0] } } Write-GlyphWarning -Message "$($ConfigPath): unknown $Setting '$Requested', using 'default'." 'default' } function Show-TerminalGlyphTheme { <# .SYNOPSIS Previews the active icon and color themes, one line per mapping. .PARAMETER Kind Which mappings to show: files, directories or both (default). .EXAMPLE Show-TerminalGlyphTheme -Kind directories .OUTPUTS System.String #> [OutputType([string])] [CmdletBinding()] param( [ValidateSet('files', 'directories')] [string[]]$Kind = @('directories', 'files') ) Initialize-TerminalGlyph $plain = $PSStyle.OutputRendering -eq 'PlainText' foreach ($kindName in $Kind) { $sections = if ($kindName -eq 'files') { @('names', 'extensions') } else { @('names') } foreach ($section in $sections) { $map = $script:TGState.Icons[$kindName][$section] foreach ($key in ($map.Keys | Sort-Object)) { $sample = if ($section -eq 'extensions') { "example$key" } else { $key } $resolved = Resolve-TerminalGlyph -Name $sample -Directory:($kindName -eq 'directories') $text = '{0} {1,-28} {2}' -f $resolved.Icon, $key, "$kindName.$section" if ($resolved.Color -and -not $plain) { "$($resolved.Color)$text$($PSStyle.Reset)" } else { $text } } } } } function Test-GlyphThemeEntry { [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [psobject]$Entry, [Parameter(Mandatory)] [ValidateSet('Icon', 'Color')] [string]$ThemeType, [scriptblock]$GlyphExists = { $true } ) $where = if ($null -eq $Entry.Section) { "$($Entry.Kind)" } elseif ($null -eq $Entry.Key) { "$($Entry.Kind).$($Entry.Section)" } else { "$($Entry.Kind).$($Entry.Section)[$($Entry.Key)]" } if ($Entry.Kind -cnotin 'files', 'directories') { return "unknown section '$($Entry.Kind)'" } if ($null -eq $Entry.Section) { return "'$where' must be an object" } $sections = if ($Entry.Kind -ceq 'files') { 'names', 'extensions', 'links', 'default' } else { 'names', 'links', 'default' } if ($Entry.Section -cnotin $sections) { return "unknown section '$where'" } if ($Entry.Section -ceq 'default') { if ($null -ne $Entry.Key) { return "'$where' must be a string" } } elseif ($null -eq $Entry.Key) { return "'$where' must be an object" } if ($Entry.Section -ceq 'links' -and $Entry.Key -cnotin 'symlink', 'junction') { return "unknown link type at '$where'" } if ($Entry.Section -ceq 'extensions' -and -not $Entry.Key.StartsWith('.')) { return "extension must start with '.' at '$where'" } if ($Entry.Value -isnot [string]) { return "value at '$where' must be a string" } if ($ThemeType -eq 'Icon') { if (-not (& $GlyphExists $Entry.Value)) { return "unknown glyph '$($Entry.Value)' at '$where'" } } elseif ($Entry.Value -notmatch '^#?[0-9A-Fa-f]{6}$') { return "invalid color '$($Entry.Value)' at '$where'" } } function Update-ProfileImport { <# .SYNOPSIS Makes a PowerShell profile import TerminalGlyphs instead of Terminal-Icons. .DESCRIPTION Edits the first profile in -Path that already imports Terminal-Icons or TerminalGlyphs, or else the first profile in -Path. Keeps a backup, the file encoding and the line endings, and writes atomically. A file without BOM keeps every byte outside the edit, whether it is UTF-8 or ANSI. A symbolic link is followed to its final target, which is edited in place of the link. #> [OutputType([pscustomobject])] [CmdletBinding(SupportsShouldProcess)] param( [string[]]$Path = @($PROFILE.CurrentUserCurrentHost, $PROFILE.CurrentUserAllHosts, $PROFILE.AllUsersCurrentHost, $PROFILE.AllUsersAllHosts) ) $oldImport = '(?im)^(?<lead>[ \t]*Import-Module[ \t]+(?:-Name[ \t]+)?)(?<quote>[''"]?)Terminal-Icons\k<quote>(?=[ \t;]|\r?$)' $newImport = '(?im)^[ \t]*Import-Module[ \t]+(?:-Name[ \t]+)?[''"]?TerminalGlyphs(?=[''" \t;]|\r?$)' $candidates = @($Path | Where-Object { $_ }) if ($candidates.Count -eq 0) { throw 'This PowerShell host has no profile path.' } $target = $candidates[0] foreach ($candidate in $candidates) { if (-not [System.IO.File]::Exists($candidate)) { continue } $content = [System.IO.File]::ReadAllText($candidate) if ($content -match $newImport -or $content -match $oldImport) { $target = $candidate break } } # Edit the final target of a symbolic link (a dotfiles repository, for example), so the link stays a link. if ($null -ne [System.IO.FileInfo]::new($target).LinkTarget) { $target = [System.IO.File]::ResolveLinkTarget($target, $true).FullName } # A new profile is UTF-8 without BOM. A file without BOM may be UTF-8 or ANSI (Windows PowerShell 5.1, old # Notepad): Latin1 round-trips every byte and the edit only touches ASCII, so either keeps its exact bytes. $encoding = [System.Text.UTF8Encoding]::new($false) $text = '' $exists = [System.IO.File]::Exists($target) if ($exists) { $reader = [System.IO.StreamReader]::new($target, [System.Text.Encoding]::Latin1, $true) try { $text = $reader.ReadToEnd() $encoding = $reader.CurrentEncoding } finally { $reader.Dispose() } } if ($text -match $newImport) { $detail = 'already imports TerminalGlyphs' if ($text -match $oldImport) { $detail += '; remove the Import-Module Terminal-Icons line' } return [pscustomobject]@{ Path = $target; Status = 'Unchanged'; Detail = $detail } } $newline = if ($text.Contains("`r`n")) { "`r`n" } elseif ($text.Contains("`n")) { "`n" } else { [Environment]::NewLine } if ($text -match $oldImport) { $updated = [regex]::Replace($text, $oldImport, '${lead}${quote}TerminalGlyphs${quote}') $action = 'Replace Import-Module Terminal-Icons with TerminalGlyphs' } else { $separator = if ($text.Length -gt 0 -and -not $text.EndsWith("`n")) { $newline } else { '' } $updated = $text + $separator + '# Added by TerminalGlyphs' + $newline + 'Import-Module -Name TerminalGlyphs' + $newline $action = 'Add Import-Module TerminalGlyphs' } if (-not $PSCmdlet.ShouldProcess($target, $action)) { return [pscustomobject]@{ Path = $target; Status = 'Skipped'; Detail = $action } } [System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($target)) | Out-Null $temp = "$target.terminalglyphs.tmp" try { [System.IO.File]::WriteAllText($temp, $updated, $encoding) if ($exists) { $backup = '{0}.terminalglyphs-{1}.bak' -f $target, [DateTime]::Now.ToString('yyyyMMddHHmmss', [cultureinfo]::InvariantCulture) [System.IO.File]::Replace($temp, $target, $backup) $action += "; backup in $backup" } else { [System.IO.File]::Move($temp, $target) } } catch { [System.IO.File]::Delete($temp) throw } # Imports the patterns cannot rewrite, such as "Import-Module posh-git, Terminal-Icons" or a one-line block. $lines = $updated -split '\r?\n' for ($index = 0; $index -lt $lines.Count; $index++) { $line = $lines[$index].TrimStart() if (-not $line.StartsWith('#') -and $line -match '(?i)(?<![\w-])Terminal-Icons(?![\w-])') { $action += "; Terminal-Icons is still imported on line $($index + 1), remove it" break } } [pscustomobject]@{ Path = $target; Status = 'OK'; Detail = $action } } function Update-TerminalGlyphConfig { <# .SYNOPSIS Reloads your TerminalGlyphs config without restarting the session. .DESCRIPTION Re-reads the config file and shows its validation warnings again, even if they were shown before. The config path is $env:TERMINALGLYPHS_CONFIG, $env:XDG_CONFIG_HOME/terminalglyphs/config.jsonc or ~/.config/terminalglyphs/config.jsonc. .EXAMPLE Update-TerminalGlyphConfig #> [CmdletBinding(SupportsShouldProcess)] param() if ($PSCmdlet.ShouldProcess((Get-ConfigPath), 'Reload TerminalGlyphs config')) { $script:Warned.Clear() Initialize-TerminalGlyph -Force } } function Write-GlyphWarning { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Message ) # Each distinct problem is reported once per session (Update-TerminalGlyphConfig resets this). if ($script:Warned.Add($Message)) { Write-Warning -Message "TerminalGlyphs: $Message" } } # Module body. build.ps1 prepends every function from src/Private and src/Public above this point. # Importing must stay cheap and must never read data files or write anything to disk. $script:DataPath = [System.IO.Path]::Combine($PSScriptRoot, 'TerminalGlyphs.data.json') $script:GlyphsPath = [System.IO.Path]::Combine($PSScriptRoot, 'glyphs.json') $script:FontsPath = [System.IO.Path]::Combine($PSScriptRoot, 'nerdfonts.json') $script:TGState = $null $script:FullGlyphs = $null $script:Warned = [System.Collections.Generic.HashSet[string]]::new() if (Get-Module -Name 'Terminal-Icons') { Write-Warning -Message 'TerminalGlyphs: Terminal-Icons is also loaded. Both modules replace the Get-ChildItem view; remove "Import-Module Terminal-Icons" from your profile.' } Update-FormatData -PrependPath ([System.IO.Path]::Combine($PSScriptRoot, 'TerminalGlyphs.format.ps1xml')) |