diff --git a/README.md b/README.md index f09a031..ae3df9c 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Tabs and nested splits in one window. Click the branch in the status bar or in t Codec, Arctic, Amber and Daylight themes -Four themes (Codec, Arctic, Amber and Daylight) drawn through everything from the prompt chips to the file icons, with blur, opacity and optional CRT scanlines. The status bar's CPU, memory and network meters rise in codec-style bars and seven-segment digits. When a long command finishes in a pane you're not watching, its tab lights up, you get a notification, and, if you like, the codec calls. +Four themes (Codec, Arctic, Amber and Daylight) drawn through everything from the prompt chips to the file icons and `ls`, which colors files as the file tree does (and with eza, shows icons in any font), with blur, opacity and optional CRT scanlines. The status bar's CPU, memory and network meters rise in codec-style bars and seven-segment digits. When a long command finishes in a pane you're not watching, its tab lights up, you get a notification, and, if you like, the codec calls. → [Settings](docs/settings.md) @@ -115,7 +115,7 @@ Most of what's above relies on Codec's zsh integration, which loads automaticall ## Built with -[SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) for terminal emulation, and Swift 6 with strict concurrency, AppKit and SwiftUI for everything else. To find your way around the code, see [Development](docs/development.md). +[SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) for terminal emulation, [Nerd Fonts](https://www.nerdfonts.com)' symbols for icons in any font, and Swift 6 with strict concurrency, AppKit and SwiftUI for everything else. To find your way around the code, see [Development](docs/development.md). ## License diff --git a/Resources/Fonts/SymbolsNerdFont-LICENSE.txt b/Resources/Fonts/SymbolsNerdFont-LICENSE.txt new file mode 100644 index 0000000..06eb073 --- /dev/null +++ b/Resources/Fonts/SymbolsNerdFont-LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Resources/Fonts/SymbolsNerdFont-Regular.ttf b/Resources/Fonts/SymbolsNerdFont-Regular.ttf new file mode 100644 index 0000000..38c24fb Binary files /dev/null and b/Resources/Fonts/SymbolsNerdFont-Regular.ttf differ diff --git a/Sources/CodecCore/Eza.swift b/Sources/CodecCore/Eza.swift new file mode 100644 index 0000000..99e7278 --- /dev/null +++ b/Sources/CodecCore/Eza.swift @@ -0,0 +1,26 @@ +import Foundation + +/// eza, the `ls` Codec can run with icons: whether it's installed, and how to install it. +public enum Eza { + /// Where Homebrew (on Apple silicon and on Intel) and Cargo put eza. The shell may find it + /// elsewhere on its PATH, so this only decides whether Settings offers to install it. + static func locations(home: String) -> [String] { + ["/opt/homebrew/bin/eza", "/usr/local/bin/eza", home + "/.cargo/bin/eza"] + } + + static let homebrews = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"] + + /// eza's own instructions, for a Mac without Homebrew. + public static let instructions = URL(string: "https://github.com/eza-community/eza/blob/main/INSTALL.md")! + + public static func isInstalled(home: String = NSHomeDirectory(), + isExecutable: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) }) -> Bool { + locations(home: home).contains(where: isExecutable) + } + + /// The command that installs eza with Homebrew, or nil without Homebrew. It names Homebrew by its + /// full path, so it runs even from a shell whose PATH doesn't include it. + public static func installCommand(isExecutable: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) }) -> String? { + homebrews.first(where: isExecutable).map { "\($0) install eza" } + } +} diff --git a/Sources/CodecCore/FileListingColors.swift b/Sources/CodecCore/FileListingColors.swift new file mode 100644 index 0000000..8c28e2c --- /dev/null +++ b/Sources/CodecCore/FileListingColors.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Colors for file listings in the shell: macOS's `ls` (through `CLICOLOR` and `LSCOLORS`), and eza, +/// GNU `ls` and zsh's completion lists (through `LS_COLORS`). +/// +/// Files take the colors their icons have in the file tree. They're named as terminal colors, so a +/// listing follows the theme, even one printed before the theme changed. Only the accent, which isn't +/// one of the 16, is given as 24-bit color. +public enum FileListingColors { + /// For macOS's `ls`: folders bold blue, links cyan, sockets magenta, pipes yellow, executables + /// green, then the system's defaults for devices and special permissions. + public static let bsd = "Exgxfxdxcxegedabagacad" + + /// The same kinds for `LS_COLORS`, which can also give folders the brighter blue. + static let kinds = "di=01;94:ln=36:so=35:pi=33:ex=32:bd=34;46:cd=34;43:su=30;41:sg=30;46:tw=30;42:ow=30;43:or=31:mi=31" + + /// `LS_COLORS`: the kinds of file, then each extension Codec knows in its type's color. + /// `accent` is the theme's accent as SGR parameters (`38;2;r;g;b`); without it, types in the + /// accent are left plain. + public static func gnu(accent: String?) -> String { + var entries = [kinds] + for type in FileType.allCases { + guard let sgr = sgr(for: type.color, accent: accent) else { continue } + entries += type.extensions.map { "*.\($0)=\(sgr)" } + } + return entries.joined(separator: ":") + } + + /// SGR parameters for a palette color: the terminal color itself, or for a neutral, plain text + /// unless it's muted enough to read as gray. + static func sgr(for color: FileColor, accent: String?) -> String? { + switch color { + case .ansi(let index) where (0..<8).contains(index): "3\(index)" + case .ansi(let index) where (8..<16).contains(index): "9\(index - 8)" + case .ansi: nil + case .accent: accent + case .neutral(let amount): amount < 0.6 ? "90" : nil + } + } + + /// `environment` with listings in color: `CLICOLOR`, `LSCOLORS` and `LS_COLORS`, each unless it's + /// already set. The user's startup files run later, so what they set wins too. + public static func environment(_ environment: [String: String], accent: String?) -> [String: String] { + var result = environment + if result["CLICOLOR"] == nil { result["CLICOLOR"] = "1" } + if result["LSCOLORS"] == nil { result["LSCOLORS"] = bsd } + if result["LS_COLORS"] == nil { result["LS_COLORS"] = gnu(accent: accent) } + return result + } +} diff --git a/Sources/CodecCore/FileType.swift b/Sources/CodecCore/FileType.swift index cecd6b7..b5253ec 100644 --- a/Sources/CodecCore/FileType.swift +++ b/Sources/CodecCore/FileType.swift @@ -60,6 +60,42 @@ public enum FileType: String, CaseIterable, Sendable { for (type, extensions) in groups { for ext in extensions { map[ext] = type } } return map }() + + /// The extensions that mean this type, sorted. + public var extensions: [String] { + Self.byExtension.filter { $0.value == self }.map(\.key).sorted() + } + + /// The color a file of this type is shown in: its icon in the file tree, its name in `ls`. + public var color: FileColor { + switch self { + case .swift, .java, .html, .git: .ansi(9) + case .javascript, .json, .environment, .archive: .ansi(3) + case .typescript, .lua, .readme: .ansi(4) + case .python, .sql: .ansi(6) + case .ruby, .pdf: .ansi(1) + case .go, .xml: .ansi(14) + case .rust, .manifest, .license: .ansi(11) + case .c, .cpp, .objectiveC, .css, .markdown, .docker: .ansi(12) + case .kotlin, .csharp, .yaml, .image: .ansi(5) + case .php, .toml, .vector, .video: .ansi(13) + case .csv: .ansi(2) + case .shell, .audio: .accent + case .text, .config, .font: .neutral(0.62) + case .lockfile, .binary: .neutral(0.5) + case .log, .other: .neutral(0.45) + } + } +} + +/// A color from the theme, named the way the terminal knows it, so `ls` can show it too. +public enum FileColor: Equatable, Sendable { + /// One of the 16 terminal colors. + case ansi(Int) + /// The theme's accent. + case accent + /// The text color washed over the background by this fraction: muted, the lower the more. + case neutral(Double) } /// What kind of folder a name suggests. @@ -79,4 +115,18 @@ public enum FolderType: String, CaseIterable, Sendable { default: .plain } } + + /// The color a folder of this kind is shown in, in the file tree. + public var color: FileColor { + switch self { + case .plain: .neutral(0.7) + case .source: .ansi(12) + case .tests: .ansi(10) + case .docs: .ansi(11) + case .assets: .ansi(13) + case .scripts: .ansi(14) + case .config: .neutral(0.5) + case .dependencies, .build: .neutral(0.36) + } + } } diff --git a/Sources/CodecCore/Preferences.swift b/Sources/CodecCore/Preferences.swift index e0686bb..eb4adf3 100644 --- a/Sources/CodecCore/Preferences.swift +++ b/Sources/CodecCore/Preferences.swift @@ -41,6 +41,11 @@ public final class Preferences: ObservableObject { @Published public var historyKeys: Bool { didSet { store(historyKeys, .historyKeys) } } /// Tab completion that ignores case, matches inside names and forgives typos (zsh). @Published public var friendlyCompletion: Bool { didSet { store(friendlyCompletion, .friendlyCompletion) } } + /// Colors file listings (`ls`, eza, completion lists) in the theme's palette, unless the + /// user's own color settings say otherwise. + @Published public var colorFileListings: Bool { didSet { store(colorFileListings, .colorFileListings) } } + /// Makes `ls` run eza, with icons and folders first, when it's installed (zsh). + @Published public var ezaForLs: Bool { didSet { store(ezaForLs, .ezaForLs) } } /// Keeps the commands you run, with where and how they ran, for the history. @Published public var recordHistory: Bool { didSet { store(recordHistory, .recordHistory) } } /// Opens the windows, tabs and splits from last time at launch. @@ -60,8 +65,8 @@ public final class Preferences: ObservableObject { private enum Key: String { case themeID, fontName, fontSize, opacity, blur, scanlines, dimInactivePanes, bell, notifyInBackground, codecSounds, paneSounds, soundVolume, optionAsMeta, exitBehavior, shellIntegration, shellPath, - showSystemStats, prompt, copyOnSelect, suggestCommands, historyKeys, friendlyCompletion, recordHistory, - restoreWindows, restorePaneContents, showUsualDuration, explainFailures, checkForUpdates + showSystemStats, prompt, copyOnSelect, suggestCommands, historyKeys, friendlyCompletion, colorFileListings, + ezaForLs, recordHistory, restoreWindows, restorePaneContents, showUsualDuration, explainFailures, checkForUpdates } private static let factoryDefaults: [Key: Any] = [ @@ -86,6 +91,8 @@ public final class Preferences: ObservableObject { .suggestCommands: true, .historyKeys: true, .friendlyCompletion: true, + .colorFileListings: true, + .ezaForLs: false, .recordHistory: true, .restoreWindows: true, .restorePaneContents: true, @@ -118,6 +125,8 @@ public final class Preferences: ObservableObject { suggestCommands = defaults.bool(forKey: Key.suggestCommands.rawValue) historyKeys = defaults.bool(forKey: Key.historyKeys.rawValue) friendlyCompletion = defaults.bool(forKey: Key.friendlyCompletion.rawValue) + colorFileListings = defaults.bool(forKey: Key.colorFileListings.rawValue) + ezaForLs = defaults.bool(forKey: Key.ezaForLs.rawValue) recordHistory = defaults.bool(forKey: Key.recordHistory.rawValue) restoreWindows = defaults.bool(forKey: Key.restoreWindows.rawValue) restorePaneContents = defaults.bool(forKey: Key.restorePaneContents.rawValue) diff --git a/Sources/CodecCore/ShellIntegration.swift b/Sources/CodecCore/ShellIntegration.swift index 7a4bea3..ca469c3 100644 --- a/Sources/CodecCore/ShellIntegration.swift +++ b/Sources/CodecCore/ShellIntegration.swift @@ -6,7 +6,8 @@ import Foundation /// - OSC 6973 `exec;` and `done;;` (see `IntegrationEvent`). /// /// It also adds history suggestions as you type, ↑ and ⌃R for Codec's history, and friendlier Tab -/// completion, each when Codec sets `CODEC_SUGGESTIONS`, `CODEC_HISTORY_KEYS` or `CODEC_COMPLETION`. +/// completion, each when Codec sets `CODEC_SUGGESTIONS`, `CODEC_HISTORY_KEYS` or `CODEC_COMPLETION`, and +/// makes `ls` run eza when it sets `CODEC_EZA`. /// /// The user's own startup files still load, unchanged: the bootstrap `.zshenv` restores `ZDOTDIR` /// before zsh looks for `.zprofile` and `.zshrc`. @@ -411,6 +412,17 @@ public struct ShellIntegration: Sendable { unsetopt LIST_BEEP } + # --- Listing files ------------------------------------------------------------------ + # ls runs eza, with icons and folders first, when it's installed. An ls alias or function of your + # own wins, unless it only turns on color. + _codec_setup_listing() { + [[ $CODEC_EZA == 1 ]] && (( $+commands[eza] )) || return 0 + (( $+functions[ls] )) && return 0 + setopt localoptions extendedglob + [[ -z ${aliases[ls]} || ${aliases[ls]} == (command |)ls( ##(-G|--color(=auto|=tty|)))# ]] || return 0 + alias ls='eza --icons=auto --group-directories-first' + } + # Install on the first prompt so these hooks wrap whatever .zshrc set up. _codec_install() { precmd_functions=(_codec_capture_status ${precmd_functions:#_codec_install} _codec_precmd) @@ -419,6 +431,7 @@ public struct ShellIntegration: Sendable { _codec_setup_suggestions _codec_setup_history_keys _codec_setup_completion + _codec_setup_listing [[ -n $HISTFILE ]] && _codec_osc "6973;histfile;$HISTFILE" _codec_precmd } diff --git a/Sources/CodecUI/AppDelegate.swift b/Sources/CodecUI/AppDelegate.swift index 5cdfeef..a6dbc4b 100644 --- a/Sources/CodecUI/AppDelegate.swift +++ b/Sources/CodecUI/AppDelegate.swift @@ -209,10 +209,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation, } } + /// Runs `command` in a new tab of the frontmost Codec window (Settings may be in front of it), or + /// in a new window when none is open. + private func runInNewTab(_ command: String) { + if let frontmost = NSApp.orderedWindows.lazy.compactMap({ window in self.windows.first { $0.window === window } }).first { + frontmost.run(command) + } else { + let controller = makeWindow(restoring: nil) + open(controller) + controller.run(command, inNewTab: false) + } + } + @objc func showSettings(_ sender: Any?) { if settingsWindow == nil { settingsWindow = SettingsView.makeWindow(preferences: environment.preferences, sounds: environment.sounds, history: environment.history, + runCommand: { [weak self] in self?.runInNewTab($0) }, updates: updater.map { updater in SettingsView.Updates(version: "\(updater.current)") { [weak updater] in updater?.checkNow() diff --git a/Sources/CodecUI/CodecGlyphs.swift b/Sources/CodecUI/CodecGlyphs.swift index eebee01..2c35bbb 100644 --- a/Sources/CodecUI/CodecGlyphs.swift +++ b/Sources/CodecUI/CodecGlyphs.swift @@ -7,21 +7,29 @@ import CodecCore /// (U+1FBF0–U+1FBF9) and its corners (⌈ ⌋). They come from a small TrueType font built here, in memory, /// which SwiftTerm falls back to when the terminal's font doesn't have them; a font that does keeps its own. /// +/// SwiftTerm takes one fallback font, so Nerd Fonts' icons come through this one too: it cascades to +/// `SymbolFont` for the characters it doesn't have. +/// /// CoreText fonts and descriptors are immutable and safe to share between threads; the cache is behind a lock. final class CodecGlyphs: TerminalGlyphFallbackProvider, @unchecked Sendable { static let shared = CodecGlyphs() - let generation: UInt64 = 1 + let generation: UInt64 = 2 private let descriptor: CTFontDescriptor? private let lock = NSLock() private var fonts: [CGFloat: CTFont] = [:] private init() { - descriptor = CTFontManagerCreateFontDescriptorFromData(GlyphFont.data as CFData) + let own = CTFontManagerCreateFontDescriptorFromData(GlyphFont.data as CFData) + if let own, let symbols = SymbolFont.descriptor { + descriptor = CTFontDescriptorCreateCopyWithAttributes(own, [kCTFontCascadeListAttribute: [symbols]] as CFDictionary) + } else { + descriptor = own + } } func placementPolicy(for scalar: Unicode.Scalar) -> TerminalGlyphPlacementPolicy? { - GlyphFont.policies[scalar] + GlyphFont.policies[scalar] ?? SymbolFont.policy(for: scalar) } func fallbackFont(forPointSize size: CGFloat) -> CTFont? { diff --git a/Sources/CodecUI/FileIcons.swift b/Sources/CodecUI/FileIcons.swift index 168b0ce..336ad57 100644 --- a/Sources/CodecUI/FileIcons.swift +++ b/Sources/CodecUI/FileIcons.swift @@ -56,19 +56,19 @@ enum FileIcons { theme.background.blended(withFraction: amount ?? (theme.isDark ? 0.2 : 0.16), of: color) ?? color } - private static func folderColor(_ folder: FolderType, theme: CodecTheme) -> NSColor { - switch folder { - case .plain: neutral(theme, 0.7) - case .source: ansi(12, theme) - case .tests: ansi(10, theme) - case .docs: ansi(11, theme) - case .assets: ansi(13, theme) - case .scripts: ansi(14, theme) - case .config: neutral(theme, 0.5) - case .dependencies, .build: neutral(theme, 0.36) + /// A palette color, as the theme draws it. + static func color(_ color: FileColor, theme: CodecTheme) -> NSColor { + switch color { + case .ansi(let index): ansi(index, theme) + case .accent: theme.accent + case .neutral(let amount): neutral(theme, amount) } } + private static func folderColor(_ folder: FolderType, theme: CodecTheme) -> NSColor { + color(folder.color, theme: theme) + } + // MARK: Marks enum Mark: Equatable { @@ -79,58 +79,59 @@ enum FileIcons { } private static func style(for type: FileType, theme: CodecTheme) -> (mark: Mark, color: NSColor) { - let a = { (index: Int) in ansi(index, theme) } + (mark(for: type), color(type.color, theme: theme)) + } + + /// The mark a type is drawn with. + static func mark(for type: FileType) -> Mark { switch type { - case .swift: return (.letters("SW"), a(9)) - case .javascript: return (.letters("JS"), a(3)) - case .typescript: return (.letters("TS"), a(4)) - case .python: return (.letters("PY"), a(6)) - case .ruby: return (.letters("RB"), a(1)) - case .go: return (.letters("GO"), a(14)) - case .rust: return (.letters("RS"), a(11)) - case .c: return (.letters("C"), a(12)) - case .cpp: return (.letters("C+"), a(12)) - case .objectiveC: return (.letters("OC"), a(12)) - case .java: return (.letters("JV"), a(9)) - case .kotlin: return (.letters("KT"), a(5)) - case .csharp: return (.letters("C#"), a(5)) - case .php: return (.letters("PH"), a(13)) - case .lua: return (.letters("LU"), a(4)) - case .sql: return (.letters("SQ"), a(6)) - case .html: return (.angle, a(9)) - case .css: return (.letters("#"), a(12)) - case .shell: return (.prompt, theme.accent) - case .markdown: return (.letters("MD"), a(12)) - case .text: return (.lines, neutral(theme)) - case .pdf: return (.lines, a(1)) - case .json: return (.braces, a(3)) - case .yaml: return (.indented, a(5)) - case .toml: return (.indented, a(13)) - case .xml: return (.angle, a(14)) - case .config: return (.sliders, neutral(theme)) - case .log: return (.lines, neutral(theme, 0.45)) - case .csv: return (.grid, a(2)) - case .image: return (.picture, a(5)) - case .vector: return (.curve, a(13)) - case .audio: return (.bars, theme.accent) - case .video: return (.play, a(13)) - case .font: return (.typeface, neutral(theme)) - case .manifest: return (.cube, a(11)) - case .lockfile: return (.padlock, neutral(theme, 0.5)) - case .docker: return (.containers, a(12)) - case .git: return (.branch, a(9)) - case .environment: return (.key, a(3)) - case .license: return (.check, a(11)) - case .readme: return (.info, a(4)) - case .archive: return (.zipper, a(3)) - case .binary: return (.letters("01"), neutral(theme, 0.5)) - case .other: return (.lines, neutral(theme, 0.45)) + case .swift: return .letters("SW") + case .javascript: return .letters("JS") + case .typescript: return .letters("TS") + case .python: return .letters("PY") + case .ruby: return .letters("RB") + case .go: return .letters("GO") + case .rust: return .letters("RS") + case .c: return .letters("C") + case .cpp: return .letters("C+") + case .objectiveC: return .letters("OC") + case .java: return .letters("JV") + case .kotlin: return .letters("KT") + case .csharp: return .letters("C#") + case .php: return .letters("PH") + case .lua: return .letters("LU") + case .sql: return .letters("SQ") + case .html: return .angle + case .css: return .letters("#") + case .shell: return .prompt + case .markdown: return .letters("MD") + case .text: return .lines + case .pdf: return .lines + case .json: return .braces + case .yaml: return .indented + case .toml: return .indented + case .xml: return .angle + case .config: return .sliders + case .log: return .lines + case .csv: return .grid + case .image: return .picture + case .vector: return .curve + case .audio: return .bars + case .video: return .play + case .font: return .typeface + case .manifest: return .cube + case .lockfile: return .padlock + case .docker: return .containers + case .git: return .branch + case .environment: return .key + case .license: return .check + case .readme: return .info + case .archive: return .zipper + case .binary: return .letters("01") + case .other: return .lines } } - /// The mark a type is drawn with, for tests. - static func mark(for type: FileType) -> Mark { style(for: type, theme: .codec).mark } - // MARK: Drawing /// A page with its top-right corner folded over. diff --git a/Sources/CodecUI/Pane.swift b/Sources/CodecUI/Pane.swift index e1ed90b..3394df7 100644 --- a/Sources/CodecUI/Pane.swift +++ b/Sources/CodecUI/Pane.swift @@ -229,6 +229,10 @@ final class Pane: LocalProcessTerminalViewDelegate { // Suggestions in the theme's muted color; the completion menu's pick in its selection color. env["CODEC_SUGGESTION_STYLE"] = "fg=" + (theme.background.blended(withFraction: 0.45, of: theme.foreground) ?? theme.foreground).hexString env["CODEC_MENU_SELECTION"] = theme.selection.sgrBackground + env["CODEC_EZA"] = preferences.ezaForLs ? "1" : "0" + if preferences.colorFileListings { + env = FileListingColors.environment(env, accent: theme.accent.sgrForeground) + } if preferences.shellIntegration, let integration = environment.shellIntegration { env = integration.environment(env, forShell: shell) } diff --git a/Sources/CodecUI/SettingsView.swift b/Sources/CodecUI/SettingsView.swift index 1bd2239..e6825fe 100644 --- a/Sources/CodecUI/SettingsView.swift +++ b/Sources/CodecUI/SettingsView.swift @@ -12,13 +12,15 @@ struct SettingsView: View { let preferences: Preferences let sounds: any SoundPlaying var history: HistoryStore? + /// Runs a command in a new terminal tab: nil where there's no app to open one. + var runCommand: ((String) -> Void)? var updates: Updates? var body: some View { TabView { AppearanceSettings(prefs: preferences) .tabItem { Label("Appearance", systemImage: "paintpalette") } - ShellSettings(prefs: preferences, history: history) + ShellSettings(prefs: preferences, history: history, runCommand: runCommand) .tabItem { Label("Shell", systemImage: "terminal") } PromptSettings(prefs: preferences) .tabItem { Label("Prompt", systemImage: "chevron.right.square") } @@ -34,9 +36,9 @@ struct SettingsView: View { /// The Settings window: resizable, sized to fit the screen, with each tab scrolling on its own. @MainActor static func makeWindow(preferences: Preferences, sounds: any SoundPlaying, history: HistoryStore? = nil, - updates: Updates? = nil) -> NSWindow { + runCommand: ((String) -> Void)? = nil, updates: Updates? = nil) -> NSWindow { let controller = NSHostingController(rootView: SettingsView(preferences: preferences, sounds: sounds, history: history, - updates: updates)) + runCommand: runCommand, updates: updates)) controller.sizingOptions = [.minSize] let window = NSWindow(contentViewController: controller) window.title = "Settings" @@ -157,7 +159,10 @@ private struct ThemeCard: View { private struct ShellSettings: View { @ObservedObject var prefs: Preferences var history: HistoryStore? + var runCommand: ((String) -> Void)? @State private var confirmingClear = false + /// Checked again whenever a window comes forward, so the offer to install goes once it's done. + @State private var ezaInstalled = Eza.isInstalled() var body: some View { Form { @@ -183,6 +188,19 @@ private struct ShellSettings: View { Text("Suggestions appear dimmed after the cursor: → or End takes them, ⌥→ one word. Friendlier completion ignores case, matches inside names (“dev” finds “Development”), forgives a typo in longer words and lets you pick with the arrows. Settings from your .zshrc win. Applies to new panes; zsh only.") .foregroundStyle(.secondary) } + Section { + Toggle("Color files by type", isOn: $prefs.colorFileListings) + Toggle("Use eza for ls, with icons", isOn: $prefs.ezaForLs) + if !ezaInstalled { installEza } + } header: { + Text("Listing files") + } footer: { + Text("Colors ls, eza and Tab completion in the theme’s palette, as files are in the file tree, unless your .zshrc sets its own. With eza, ls shows an icon by each name and folders first. Icons show in any font. Applies to new panes; eza in zsh only.") + .foregroundStyle(.secondary) + } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in + ezaInstalled = Eza.isInstalled() + } Section { Toggle("Offer to explain failed commands", isOn: $prefs.explainFailures) } header: { @@ -225,6 +243,24 @@ private struct ShellSettings: View { } .formStyle(.grouped) } + + /// With Homebrew, installing runs in a new tab, where you can watch it (and answer it), and turns + /// eza on for the panes after it. Without, eza's own instructions. + @ViewBuilder private var installEza: some View { + LabeledContent { + if let command = Eza.installCommand(), let runCommand { + Button("Install with Homebrew") { + prefs.ezaForLs = true + runCommand(command) + } + .help("Runs \(command) in a new tab") + } else { + Link("How to Install eza…", destination: Eza.instructions) + } + } label: { + Text("eza isn’t installed").foregroundStyle(.secondary) + } + } } private struct NotificationSettings: View { diff --git a/Sources/CodecUI/SymbolFont.swift b/Sources/CodecUI/SymbolFont.swift new file mode 100644 index 0000000..f3d45df --- /dev/null +++ b/Sources/CodecUI/SymbolFont.swift @@ -0,0 +1,59 @@ +import CoreText +import Foundation +import SwiftTerm + +/// Nerd Fonts' symbols: the file, folder, language and tool icons that eza, lsd, starship and editors' +/// file trees print as private-use characters. Codec's glyph font falls back to this one, so they show +/// in any font; a font patched with them keeps its own. +/// +/// The font ships in the app's Resources/Fonts (MIT; see SymbolsNerdFont-LICENSE.txt beside it). +enum SymbolFont { + static let fileName = "SymbolsNerdFont-Regular.ttf" + + /// The font, or nil when it can't be found or read: icons then show as the terminal font draws them. + /// Descriptors are immutable, so safe to share between threads. + nonisolated(unsafe) static let descriptor: CTFontDescriptor? = { + guard let url = location, let data = try? Data(contentsOf: url) else { return nil } + return CTFontManagerCreateFontDescriptorFromData(data as CFData) + }() + + /// In the app bundle, or, for development builds and tests, in the repository. + private static var location: URL? { + let fileManager = FileManager.default + let candidates = [ + Bundle.main.resourceURL?.appendingPathComponent("Fonts/\(fileName)"), + URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().appendingPathComponent("Resources/Fonts/\(fileName)"), + ] + return candidates.compactMap { $0 }.first { fileManager.fileExists(atPath: $0.path) } + } + + /// The characters the font has where icons live: the private-use areas, and the power symbols + /// (⏻ ⏼ ⏽ ⏾ ⭘). It has a few ordinary symbols too (♥ ⚡ ❮ ❯), left to the system's fallback. + static let coverage: CharacterSet = { + guard let descriptor else { return CharacterSet() } + let all = CTFontCopyCharacterSet(CTFontCreateWithFontDescriptor(descriptor, 12, nil)) as CharacterSet + var icons = CharacterSet() + for range in iconRanges { + icons.insert(charactersIn: Unicode.Scalar(range.lowerBound)!...Unicode.Scalar(range.upperBound)!) + } + return all.intersection(icons) + }() + + private static let iconRanges: [ClosedRange] = [0x23FB...0x23FE, 0x2B58...0x2B58, 0xE000...0xF8FF, 0xF0000...0xFFFFD] + + /// Powerline's separators and their variants (flames, pixels, slants) fill the cell, to join the + /// colored segments on either side. SwiftTerm draws the common ones itself. + private static let separators: ClosedRange = 0xE0B0...0xE0D7 + + private static let icon = TerminalGlyphPlacementPolicy(placement: .icon) + private static let separator = TerminalGlyphPlacementPolicy(placement: .stretch) + + /// How `scalar` is placed, or nil when the font doesn't have it. Asked for every cell drawn, so the + /// ranges are checked first. + static func policy(for scalar: Unicode.Scalar) -> TerminalGlyphPlacementPolicy? { + let value = scalar.value + guard value >= 0x23FB, iconRanges.contains(where: { $0.contains(value) }), coverage.contains(scalar) else { return nil } + return separators.contains(value) ? separator : icon + } +} diff --git a/Sources/CodecUI/TerminalWindowController.swift b/Sources/CodecUI/TerminalWindowController.swift index c0b76db..9f8506f 100644 --- a/Sources/CodecUI/TerminalWindowController.swift +++ b/Sources/CodecUI/TerminalWindowController.swift @@ -468,6 +468,15 @@ final class TerminalWindowController: NSWindowController, NSWindowDelegate, NSMe return true } + /// Runs `command` in a new tab, or in the focused pane of a window that's just opened, as soon as + /// its shell starts; brings the window to the front. + func run(_ command: String, inNewTab: Bool = true) { + if inNewTab { newTab(nil) } + focusedPane?.terminal.send(txt: command + "\r") + window?.makeKeyAndOrderFront(nil) + focusTerminal() + } + /// Run or Insert on a code block in a Markdown document. private func runFromDocument(_ code: String, run: Bool) { guard let pane = focusedPane, pane.replaceCommandLine(with: code, run: run) else { return NSSound.beep() } diff --git a/Sources/CodecUI/Theme.swift b/Sources/CodecUI/Theme.swift index 2ac6b4e..545da9e 100644 --- a/Sources/CodecUI/Theme.swift +++ b/Sources/CodecUI/Theme.swift @@ -133,6 +133,12 @@ extension NSColor { return "48;2;\(Int(round(c.redComponent * 255)));\(Int(round(c.greenComponent * 255)));\(Int(round(c.blueComponent * 255)))" } + /// A 24-bit foreground color as SGR parameters, as `LS_COLORS` takes them. + var sgrForeground: String { + let c = usingColorSpace(.sRGB) ?? self + return "38;2;\(Int(round(c.redComponent * 255)));\(Int(round(c.greenComponent * 255)));\(Int(round(c.blueComponent * 255)))" + } + convenience init(hex: UInt32) { self.init( srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, diff --git a/Tests/CodecCoreTests/FileListingColorsTests.swift b/Tests/CodecCoreTests/FileListingColorsTests.swift new file mode 100644 index 0000000..da514a3 --- /dev/null +++ b/Tests/CodecCoreTests/FileListingColorsTests.swift @@ -0,0 +1,77 @@ +import Testing +@testable import CodecCore + +@Suite("File listing colors") +struct FileListingColorsTests { + private func entries(accent: String? = "38;2;1;2;3") -> [String: String] { + Dictionary(FileListingColors.gnu(accent: accent).split(separator: ":").map { entry in + let parts = entry.split(separator: "=", maxSplits: 1) + return (String(parts[0]), String(parts[1])) + }, uniquingKeysWith: { _, new in new }) + } + + /// The terminal's colors, so a listing follows the theme: 0–7 are 30–37, the bright ones 90–97. + @Test func namesTerminalColors() { + #expect(FileListingColors.sgr(for: .ansi(1), accent: nil) == "31") + #expect(FileListingColors.sgr(for: .ansi(9), accent: nil) == "91") + #expect(FileListingColors.sgr(for: .ansi(15), accent: nil) == "97") + #expect(FileListingColors.sgr(for: .accent, accent: "38;2;1;2;3") == "38;2;1;2;3") + #expect(FileListingColors.sgr(for: .accent, accent: nil) == nil) + #expect(FileListingColors.sgr(for: .neutral(0.45), accent: nil) == "90") + #expect(FileListingColors.sgr(for: .neutral(0.62), accent: nil) == nil) + } + + /// Files take their icon's color from the file tree. + @Test func colorsFilesAsTheFileTreeDoes() { + let colors = entries() + #expect(colors["*.swift"] == "91") + #expect(colors["*.md"] == "94") + #expect(colors["*.json"] == "33") + #expect(colors["*.zip"] == "33") + #expect(colors["*.sh"] == "38;2;1;2;3") + #expect(colors["*.log"] == "90") + #expect(colors["*.lock"] == "90") + // Plain text stays plain. + #expect(colors["*.txt"] == nil) + #expect(entries(accent: nil)["*.sh"] == nil) + } + + @Test func colorsKindsOfFile() { + let colors = entries() + #expect(colors["di"] == "01;94") + #expect(colors["ln"] == "36") + #expect(colors["ex"] == "32") + #expect(FileListingColors.bsd.count == 22) + #expect(FileListingColors.bsd.hasPrefix("Exgx")) + } + + @Test func fillsInOnlyWhatIsntSet() { + let fresh = FileListingColors.environment(["PATH": "/bin"], accent: nil) + #expect(fresh["CLICOLOR"] == "1") + #expect(fresh["LSCOLORS"] == FileListingColors.bsd) + #expect(fresh["LS_COLORS"] == FileListingColors.gnu(accent: nil)) + #expect(fresh["PATH"] == "/bin") + + let own = FileListingColors.environment(["CLICOLOR": "", "LSCOLORS": "exfxcxdxbxegedabagacad", "LS_COLORS": "di=31"], accent: nil) + #expect(own["CLICOLOR"] == "") + #expect(own["LSCOLORS"] == "exfxcxdxbxegedabagacad") + #expect(own["LS_COLORS"] == "di=31") + } +} + +@Suite("eza") +struct EzaTests { + @Test func looksWhereHomebrewAndCargoPutIt() { + #expect(Eza.isInstalled(home: "/Users/me") { $0 == "/opt/homebrew/bin/eza" }) + #expect(Eza.isInstalled(home: "/Users/me") { $0 == "/usr/local/bin/eza" }) + #expect(Eza.isInstalled(home: "/Users/me") { $0 == "/Users/me/.cargo/bin/eza" }) + #expect(!Eza.isInstalled(home: "/Users/me") { _ in false }) + } + + /// Homebrew by its full path, which works even where the shell's PATH lacks it; nil without it. + @Test func installsWithHomebrewWhereThereIsOne() { + #expect(Eza.installCommand { $0 == "/opt/homebrew/bin/brew" } == "/opt/homebrew/bin/brew install eza") + #expect(Eza.installCommand { $0 == "/usr/local/bin/brew" } == "/usr/local/bin/brew install eza") + #expect(Eza.installCommand { _ in false } == nil) + } +} diff --git a/Tests/CodecCoreTests/FileTypeTests.swift b/Tests/CodecCoreTests/FileTypeTests.swift index fe4dc8b..8fe336d 100644 --- a/Tests/CodecCoreTests/FileTypeTests.swift +++ b/Tests/CodecCoreTests/FileTypeTests.swift @@ -28,4 +28,10 @@ struct FileTypeTests { func classifiesFolders(name: String, expected: FolderType) { #expect(FolderType.of(folderNamed: name) == expected) } + + @Test func listsATypesExtensions() { + #expect(FileType.swift.extensions == ["swift"]) + #expect(FileType.typescript.extensions == ["cts", "mts", "ts", "tsx"]) + #expect(FileType.other.extensions.isEmpty) + } } diff --git a/Tests/CodecCoreTests/ShellIntegrationTests.swift b/Tests/CodecCoreTests/ShellIntegrationTests.swift index 0057920..7f54f94 100644 --- a/Tests/CodecCoreTests/ShellIntegrationTests.swift +++ b/Tests/CodecCoreTests/ShellIntegrationTests.swift @@ -809,3 +809,41 @@ struct HistoryAndCompletionTests { } } + +@Suite("Listing files in zsh", .serialized, .timeLimit(.minutes(1)), + .enabled(if: FileManager.default.isExecutableFile(atPath: "/bin/zsh"))) +struct ListingTests { + /// A zsh whose PATH has a stand-in eza, when `eza` is true, and otherwise only the system's + /// folders, so an eza installed on this Mac isn't found. The .zshrc sets it, as a user's would. + private func shell(rc: String = "", eza: Bool = true, setting: Bool = true) async throws -> ZshHarness { + let path = eza ? "chmod +x ~/bin/eza; path=(~/bin $path)\n" : "path=(/usr/bin /bin)\n" + let zsh = try ZshHarness(homeFiles: [".zshrc": "PS1='%# '\n" + path + rc, "bin/eza": "#!/bin/sh\necho eza \"$@\"\n"], + environment: ["CODEC_EZA": setting ? "1" : "0"]) + #expect(await zsh.waitForPrompt()) + return zsh + } + + private func lsAlias(_ zsh: ZshHarness) async -> String? { + let before = zsh.payloads(9999).count + zsh.send(#"print -rn -- $'\e]9999;ls='"${aliases[ls]}"$'\a'"# + "\r") + guard await zsh.wait(until: { zsh.payloads(9999).count > before }) else { return nil } + return zsh.payloads(9999).last.map { String($0.dropFirst(3)) } + } + + @Test func lsRunsEzaWithIcons() async throws { + #expect(await lsAlias(try await shell()) == "eza --icons=auto --group-directories-first") + } + + @Test func replacesAnAliasThatOnlyTurnsOnColor() async throws { + #expect(await lsAlias(try await shell(rc: "alias ls='ls -G'")) == "eza --icons=auto --group-directories-first") + } + + @Test func leavesTheUsersOwnLsAlone() async throws { + #expect(await lsAlias(try await shell(rc: "alias ls='ls -lah'")) == "ls -lah") + } + + @Test func doesNothingWithoutEzaOrTheSetting() async throws { + #expect(await lsAlias(try await shell(eza: false)) == "") + #expect(await lsAlias(try await shell(setting: false)) == "") + } +} diff --git a/Tests/CodecUITests/CodecGlyphsTests.swift b/Tests/CodecUITests/CodecGlyphsTests.swift index b3aef3c..60ef301 100644 --- a/Tests/CodecUITests/CodecGlyphsTests.swift +++ b/Tests/CodecUITests/CodecGlyphsTests.swift @@ -34,11 +34,30 @@ struct CodecGlyphsTests { /// Claiming more would replace glyphs the terminal's font draws for other programs. @Test func claimsOnlyItsOwnCharacters() { for scalar in scalars { #expect(CodecGlyphs.shared.placementPolicy(for: scalar) != nil) } - for other: Unicode.Scalar in ["8", "|", "\u{2502}", "\u{1FBEF}", "\u{1FBFA}", "\u{E0B4}"] { - #expect(CodecGlyphs.shared.placementPolicy(for: other) == nil) + // Ordinary symbols the icon font also has (♥ ⚡ ❯) are left to the system, and a private-use + // character it lacks to whatever font has it. + for other: Unicode.Scalar in ["8", "|", "\u{2502}", "\u{1FBEF}", "\u{1FBFA}", "\u{2665}", "\u{26A1}", "\u{276F}", "\u{E0FF}", "\u{F8FF}"] { + #expect(CodecGlyphs.shared.placementPolicy(for: other) == nil, "U+\(String(other.value, radix: 16, uppercase: true))") } } + /// Nerd Fonts' icons (a folder, Swift's bird, git's branch, a power symbol) come from the symbol + /// font the glyph font cascades to, placed as icons; Powerline's separators fill their cell. + @Test func fallsBackToNerdFontIcons() throws { + let font = try #require(CodecGlyphs.shared.fallbackFont(forPointSize: 13)) + for scalar: Unicode.Scalar in ["\u{F07B}", "\u{E755}", "\u{F418}", "\u{F0001}", "\u{23FB}"] { + let policy = try #require(CodecGlyphs.shared.placementPolicy(for: scalar), "U+\(String(scalar.value, radix: 16, uppercase: true))") + #expect(policy.placement == .icon) + let line = CTLineCreateWithAttributedString(NSAttributedString(string: String(scalar), + attributes: [.init(kCTFontAttributeName as String): font])) + let run = try #require((CTLineGetGlyphRuns(line) as? [CTRun])?.first) + let runFont = (CTRunGetAttributes(run) as NSDictionary)[kCTFontAttributeName] as! CTFont + #expect(CTFontCopyPostScriptName(runFont) as String == "SymbolsNF") + } + #expect(CodecGlyphs.shared.placementPolicy(for: "\u{E0B4}")?.placement == .stretch) + #expect(CodecGlyphs.shared.placementPolicy(for: "\u{E0C0}")?.placement == .stretch) + } + /// Digits fit as one group: a 1 sits where an 8's right segments are, not stretched across the cell. @Test func digitsShareOneBox() throws { let font = try #require(CodecGlyphs.shared.fallbackFont(forPointSize: 1000)) diff --git a/Tests/CodecUITests/PaneTests.swift b/Tests/CodecUITests/PaneTests.swift index 31bf66f..74d208e 100644 --- a/Tests/CodecUITests/PaneTests.swift +++ b/Tests/CodecUITests/PaneTests.swift @@ -32,6 +32,30 @@ struct PaneTests { #expect(shellEnv["CODEC_SHELL_INTEGRATION"] == nil) } + @Test func colorsFileListingsUnlessTurnedOff() throws { + let env = try TestEnvironment() + defer { env.tearDown() } + let pane = env.makePane() + defer { pane.shutDown() } + let inherited = ProcessInfo.processInfo.environment + let colored = pane.shellEnvironment(shell: "/bin/zsh") + #expect(colored["CLICOLOR"] == inherited["CLICOLOR"] ?? "1") + #expect(colored["LSCOLORS"] == inherited["LSCOLORS"] ?? FileListingColors.bsd) + if inherited["LS_COLORS"] == nil { + #expect(colored["LS_COLORS"]?.contains("*.swift=91") == true) + // Shell scripts in the theme's accent. + #expect(colored["LS_COLORS"]?.contains("*.sh=38;2;") == true) + } + #expect(colored["CODEC_EZA"] == "0") + + env.preferences.colorFileListings = false + env.preferences.ezaForLs = true + let plain = pane.shellEnvironment(shell: "/bin/zsh") + #expect(plain["CLICOLOR"] == inherited["CLICOLOR"]) + #expect(plain["LS_COLORS"] == inherited["LS_COLORS"]) + #expect(plain["CODEC_EZA"] == "1") + } + @Test func addsIntegrationOnlyForZsh() throws { let env = try TestEnvironment(integration: true) defer { env.tearDown() } diff --git a/Tests/CodecUITests/ScreenshotTour.swift b/Tests/CodecUITests/ScreenshotTour.swift index 344b877..3b5733d 100644 --- a/Tests/CodecUITests/ScreenshotTour.swift +++ b/Tests/CodecUITests/ScreenshotTour.swift @@ -29,6 +29,10 @@ struct ScreenshotTour { } try makeHome(home, project: project) setenv("ZDOTDIR", home.path, 1) + // swift test sets NO_COLOR, which the panes would pass on to eza; Codec opened from the Finder has none. + let noColor = ProcessInfo.processInfo.environment["NO_COLOR"] + unsetenv("NO_COLOR") + defer { if let noColor { setenv("NO_COLOR", noColor, 1) } } var prompt = PromptConfiguration.default prompt.style = .codec env.preferences.prompt = prompt @@ -48,6 +52,7 @@ struct ScreenshotTour { try await history(env, project: project) try await editor(env, project: project) try await openFile(env, project: project) + try await listing(env, project: project) try await branches(env, project: project) try await themes(env, project: project) try await usualDuration(env, project: project) @@ -146,13 +151,28 @@ struct ScreenshotTour { let (controller, pane) = try await window(env, project: project, size: NSSize(width: 900, height: 480)) defer { close(controller) } try await run("ls", in: pane) - try await run("ls Sources/CodecCore | head -8", in: pane) + try await run("ls Sources/CodecCore | head -10", in: pane) try point(pane, at: "FileTree.swift") pane.refreshFileLink(modifiers: .command) try await settle(seconds: 0.5) try await capture(controller.window!, as: "open-file") } + /// `ls` through eza, with icons and folders first, then a long listing with git's status; plain + /// `ls` where eza isn't installed. + private func listing(_ env: TestEnvironment, project: URL) async throws { + if !Eza.isInstalled() { Issue.record("eza isn't installed, so the listing shows no icons") } + env.preferences.ezaForLs = true + defer { env.preferences.ezaForLs = false } + let (controller, pane) = try await window(env, project: project, size: NSSize(width: 900, height: 560)) + defer { close(controller) } + try await run("ls", in: pane) + // Not piped: eza leaves icons and color out when it isn't writing to a terminal. + try await run("ls -l --git --no-user --no-time", in: pane) + try await settle() + try await capture(controller.window!, as: "listing") + } + /// Puts the pointer on the last `name` on screen, as the terminal lays out its cells. private func point(_ pane: Pane, at name: String) throws { let grid = try #require(TerminalGrid(terminal: pane.terminal)) @@ -374,6 +394,7 @@ struct ScreenshotTour { // `make test` stands in for the test suite: test output for twelve seconds, rather than a real build. let zshrc = #""" export EDITOR=vim + path+=(/opt/homebrew/bin /usr/local/bin) HISTFILE=~/.zsh_history PS1='%~ %# ' make() { diff --git a/Tests/CodecUITests/WindowTests.swift b/Tests/CodecUITests/WindowTests.swift index 04bb913..e3db82d 100644 --- a/Tests/CodecUITests/WindowTests.swift +++ b/Tests/CodecUITests/WindowTests.swift @@ -308,6 +308,19 @@ struct WindowTests { #expect(window.model.selected?.focused.session.directory == sub.path) } + /// How Settings installs eza: the command runs in a new tab as soon as its shell starts. + @Test func runsACommandInANewTab() async throws { + let env = try TestEnvironment() + defer { env.tearDown() } + let window = makeWindow(env) + defer { close(window) } + let marker = env.directory.appendingPathComponent("ran") + window.run("touch " + ShellQuoting.quote(marker.path)) + #expect(window.model.tabs.count == 2) + #expect(window.model.selectedID == window.model.tabs[1].id) + #expect(await waitUntil { FileManager.default.fileExists(atPath: marker.path) }) + } + @Test func splitsAndClosesPanes() throws { let env = try TestEnvironment() defer { env.tearDown() } diff --git a/docs/command-line.md b/docs/command-line.md index 56d371a..f132efb 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -73,3 +73,15 @@ Codec keeps its own record in `~/Library/Application Support/Codec/history.jsonl - Ambiguous completions open a menu you can move through with the arrows, in the theme's colors, folders first. Codec loads zsh's completion system if your `.zshrc` doesn't, and never overrides completion styles you set yourself. Each of these can be turned off in Settings › Shell. + +## Listing files + +`ls` shows files in color: folders blue, links cyan, executables green, and every other file in the color its icon has in the [file tree](files-and-editor.md#file-tree), so Swift files are red and Markdown is blue in both. They're the theme's terminal colors, so a listing follows the theme. The same colors go to eza, GNU `ls` and the completion menu, through `CLICOLOR`, `LSCOLORS` and `LS_COLORS`; any of those your own startup files set wins. + +![ls through eza: an icon by each name, folders first, and a long listing with git's status](images/listing.png) + +With [eza](https://eza.rocks) installed, turn on Settings › Shell › Use eza for ls and `ls` runs `eza --icons=auto --group-directories-first`: an icon by each name, folders first. An `ls` alias or function of your own is left alone, unless all it does is turn on color (`ls -G`). zsh only. + +Without eza, Settings offers to install it: Install with Homebrew runs `brew install eza` in a new tab, where you can watch it, and turns the setting on for the panes you open after. Without Homebrew, it links to eza's install instructions. + +Icons show whatever font you use. They're Nerd Fonts' symbols, which Codec carries and falls back to when your font doesn't have them, so starship, lsd, Neovim file trees and anything else that prints them shows them too. A font patched with them (a Nerd Font) keeps its own. diff --git a/docs/development.md b/docs/development.md index 23bc3f1..3137d49 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,7 +19,7 @@ Three targets, compiled in Swift 6 language mode (strict concurrency): | Target | What's in it | |---|---| -| `CodecCore` | Logic with no UI, all of it unit-tested: the split layout tree and pane geometry, shell integration scripts and environment, integration event parsing, bell and exit policies, preferences, git branch lookup and switching, the changed lines of a file (a line diff), smart selection, command block tracking, each pane's command timeline and kept output, Intel files and their variables, syntax highlighting, Markdown rendering, file types, command history ranking and storage, process inspection, system sampling, versions and release lookup for the update check, and downloading, checking and putting a new version in place. | +| `CodecCore` | Logic with no UI, all of it unit-tested: the split layout tree and pane geometry, shell integration scripts and environment, integration event parsing, bell and exit policies, preferences, git branch lookup and switching, the changed lines of a file (a line diff), smart selection, command block tracking, each pane's command timeline and kept output, Intel files and their variables, syntax highlighting, Markdown rendering, file types and their colors (for the file tree and `ls`), command history ranking and storage, process inspection, system sampling, versions and release lookup for the update check, and downloading, checking and putting a new version in place. | | `CodecUI` | AppKit and SwiftUI: app delegate and menus, window controller, tabs, panes, chrome, sidebars, editor, palette, settings, sounds, notifications, the update check and installing. | | `Codec` | The executable; calls `CodecApp.run()`. | @@ -38,6 +38,7 @@ scripts/ build-app.sh (bundles Codec.app), release.sh (signed, nota update-cask.sh (points the cask at a release), make-signing-certificate.sh (the release signing certificate), make-icon.swift (renders the icon), screenshots.sh (retakes docs/images) +Resources/Fonts/ Nerd Fonts' symbols (MIT), the terminal's fallback for icons; build-app.sh bundles it Casks/codec.rb the Homebrew cask ``` diff --git a/docs/images/branches.png b/docs/images/branches.png index 10ef26a..a016923 100644 Binary files a/docs/images/branches.png and b/docs/images/branches.png differ diff --git a/docs/images/commands.png b/docs/images/commands.png index a27495f..5589e7c 100644 Binary files a/docs/images/commands.png and b/docs/images/commands.png differ diff --git a/docs/images/editor.png b/docs/images/editor.png index f1e011a..ea3a1d8 100644 Binary files a/docs/images/editor.png and b/docs/images/editor.png differ diff --git a/docs/images/hero.png b/docs/images/hero.png index 4b2a1ba..e249c83 100644 Binary files a/docs/images/hero.png and b/docs/images/hero.png differ diff --git a/docs/images/history.png b/docs/images/history.png index b6328b6..8580d58 100644 Binary files a/docs/images/history.png and b/docs/images/history.png differ diff --git a/docs/images/intel-document.png b/docs/images/intel-document.png index d287632..96591d3 100644 Binary files a/docs/images/intel-document.png and b/docs/images/intel-document.png differ diff --git a/docs/images/intel.png b/docs/images/intel.png index a9aa1e6..958221f 100644 Binary files a/docs/images/intel.png and b/docs/images/intel.png differ diff --git a/docs/images/listing.png b/docs/images/listing.png new file mode 100644 index 0000000..ccaf1b4 Binary files /dev/null and b/docs/images/listing.png differ diff --git a/docs/images/open-file.png b/docs/images/open-file.png index 668eeb7..2408280 100644 Binary files a/docs/images/open-file.png and b/docs/images/open-file.png differ diff --git a/docs/images/themes.png b/docs/images/themes.png index b1254e0..6f3a9d5 100644 Binary files a/docs/images/themes.png and b/docs/images/themes.png differ diff --git a/docs/images/usual-duration.png b/docs/images/usual-duration.png index c2fd231..050a39e 100644 Binary files a/docs/images/usual-duration.png and b/docs/images/usual-duration.png differ diff --git a/docs/settings.md b/docs/settings.md index f01257a..f30669e 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -16,6 +16,7 @@ Open Settings with ⌘,. Changes apply as you make them, unless a setting says i - **Shell**: the program panes run (your login shell by default), and whether Option acts as Meta. - **Shell integration**: what makes the zsh features work. See [Getting started › Shell integration](getting-started.md#shell-integration-zsh). - **Command line**: suggestions from history as you type, ↑ and ⌃R for the history menu and search, friendlier Tab completion (these apply to new panes; your `.zshrc` settings win), and [how long commands usually take](commands-sidebar.md#how-long-it-usually-takes). +- **Listing files**: whether `ls`, eza and Tab completion show files in the theme's colors, and whether `ls` runs eza, with icons (with a button to install eza when it's missing). See [The command line › Listing files](command-line.md#listing-files). These apply to new panes. - **Apple Intelligence**: whether failed commands offer to be [explained](commands-sidebar.md#why-did-it-fail) by Apple's on-device model. - **History**: whether Codec records the commands you run, and a button to forget everything recorded so far. Forgetting also hides older commands from your shell's history file in Codec, without changing the file. - **Selection**: copy text as soon as it's selected. diff --git a/scripts/build-app.sh b/scripts/build-app.sh index dce9369..baf3198 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -56,6 +56,9 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$EXECUTABLE" "$APP/Contents/MacOS/Codec" cp Resources/Info.plist "$APP/Contents/Info.plist" cp build/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" +# Nerd Fonts' symbols, which the terminal falls back to for icons the chosen font doesn't have. +mkdir -p "$APP/Contents/Resources/Fonts" +cp Resources/Fonts/* "$APP/Contents/Resources/Fonts/" # Codec sound files are optional and not in the repository; without them the app synthesizes its cues. if compgen -G "Resources/Sounds/*.mp3" >/dev/null; then mkdir -p "$APP/Contents/Resources/Sounds"