Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Tabs and nested splits in one window. Click the branch in the status bar or in t

<img src="docs/images/themes.png" alt="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)

Expand Down Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions Resources/Fonts/SymbolsNerdFont-LICENSE.txt
Original file line number Diff line number Diff line change
@@ -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.
Binary file added Resources/Fonts/SymbolsNerdFont-Regular.ttf
Binary file not shown.
26 changes: 26 additions & 0 deletions Sources/CodecCore/Eza.swift
Original file line number Diff line number Diff line change
@@ -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" }
}
}
50 changes: 50 additions & 0 deletions Sources/CodecCore/FileListingColors.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
50 changes: 50 additions & 0 deletions Sources/CodecCore/FileType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}
}
}
13 changes: 11 additions & 2 deletions Sources/CodecCore/Preferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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] = [
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion Sources/CodecCore/ShellIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import Foundation
/// - OSC 6973 `exec;<command>` and `done;<status>;<seconds>` (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`.
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
13 changes: 13 additions & 0 deletions Sources/CodecUI/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
14 changes: 11 additions & 3 deletions Sources/CodecUI/CodecGlyphs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
Loading
Loading