Skip to content

Commit 29f5cbc

Browse files
committed
First version of code
1 parent 839ac59 commit 29f5cbc

8 files changed

+375
-0
lines changed

.gitignore

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
xcuserdata/
5+
DerivedData/
6+
.swiftpm/configuration/registries.json
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.netrc

Package.swift

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// swift-tools-version: 6.0
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "swiftfzf",
8+
platforms: [.macOS(.v15)],
9+
dependencies: [],
10+
targets: [
11+
// Targets are the basic building blocks of a package, defining a module or a test suite.
12+
// Targets can depend on other targets in this package and products from dependencies.
13+
.executableTarget(
14+
name: "swiftfzf",
15+
dependencies: []
16+
),
17+
]
18+
)

Sources/ANSIControlCode.swift

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
//
2+
// ANSIControlCode.swift
3+
// swiftfzf
4+
//
5+
// Created by Juri Pakaste on 3.11.2024.
6+
//
7+
8+
enum ANSIControlCode {
9+
case clearLine
10+
case clearScreen
11+
case insertLines(Int)
12+
case key(TerminalKey)
13+
case moveCursor(x: Int, y: Int)
14+
case moveCursorUp(n: Int)
15+
case restoreCursorPosition
16+
case saveCursorPosition
17+
case scrollDown(Int)
18+
case scrollUp(Int)
19+
case setCursorHidden(Bool)
20+
21+
var ansiCommand: ANSICommand {
22+
switch self {
23+
case .clearLine: return .init(rawValue: "2K")
24+
case .clearScreen: return .init(rawValue: "2J")
25+
case .key(.down): return .init(rawValue: "B")
26+
case .key(.up): return .init(rawValue: "A")
27+
case let .insertLines(n): return .init(rawValue: "\(n)L")
28+
case let .moveCursor(x: x, y: y): return .init(rawValue: "\(y + 1);\(x + 1)H")
29+
case let .moveCursorUp(n: n): return .init(rawValue: "\(n)A")
30+
case .restoreCursorPosition: return .init(rawValue: "8")
31+
case .saveCursorPosition: return .init(rawValue: "7")
32+
case let .scrollDown(n): return .init(rawValue: "\(n)T")
33+
case let .scrollUp(n): return .init(rawValue: "\(n)S")
34+
case let .setCursorHidden(hidden): return .init(rawValue: "?25\(hidden ? "l" : "h")")
35+
}
36+
}
37+
38+
static func moveBottom<T>(viewState: ViewState<T>) -> Self {
39+
.moveCursor(x: 0, y: viewState.height - 1)
40+
}
41+
}
42+
43+
struct ANSICommand: RawRepresentable {
44+
var rawValue: String
45+
46+
var message: String {
47+
"\u{001B}[\(self.rawValue)"
48+
}
49+
}

Sources/Fzf.swift

+149
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import Foundation
2+
3+
func outputCode(_ code: ANSIControlCode) {
4+
try! FileHandle.standardOutput.write(contentsOf: Data(code.ansiCommand.message.utf8))
5+
// fdopen() on stdout is fast; also the returned file MUST NOT be fclose()d
6+
// This avoids concurrency complaints due to accessing global `stdout`.
7+
fflush(fdopen(STDOUT_FILENO, "w+"))
8+
}
9+
10+
func fillScreen<T>(viewState: ViewState<T>) throws {
11+
outputCode(.clearScreen)
12+
let choices = viewState.choices.suffix(viewState.height - 1)
13+
let startLine = viewState.height - choices.count
14+
var lineNumber = 0
15+
for (index, choice) in zip(choices.indices, choices) {
16+
outputCode(.moveCursor(x: 0, y: startLine + index))
17+
print(index == viewState.current ? "> " : " ", terminator: "")
18+
print(choice, lineNumber)
19+
lineNumber += 1
20+
}
21+
}
22+
23+
func moveUp<T>(viewState: ViewState<T>) {
24+
guard let current = viewState.current, current > 0 else { return }
25+
guard let currentLine = viewState.line(forChoiceIndex: current) else {
26+
debug("moveUp didn't receive line for current \(current)")
27+
fatalError()
28+
}
29+
30+
outputCode(.setCursorHidden(true))
31+
defer { outputCode(.setCursorHidden(false)) }
32+
33+
outputCode(.moveCursor(x: 0, y: currentLine))
34+
print(" ")
35+
36+
if currentLine > 4 || !viewState.canScrollUp {
37+
// we don't need to scroll or we can't scroll
38+
outputCode(.moveCursor(x: 0, y: currentLine - 1))
39+
print(">")
40+
viewState.moveUp()
41+
outputCode(.moveCursor(x: 0, y: viewState.height))
42+
} else {
43+
outputCode(.moveCursor(x: 0, y: 0))
44+
outputCode(.insertLines(1))
45+
viewState.moveUp()
46+
viewState.scrollUp()
47+
48+
print(" ", viewState.choices[viewState.visibleLines.lowerBound], separator: "")
49+
guard let newCurrentLine = viewState.line(forChoiceIndex: current - 1) else { fatalError() }
50+
outputCode(.moveCursor(x: 0, y: newCurrentLine))
51+
print("> ", separator: "")
52+
outputCode(.moveCursor(x: 0, y: viewState.height))
53+
outputCode(.clearLine)
54+
}
55+
}
56+
57+
func moveDown<T>(viewState: ViewState<T>) {
58+
guard let current = viewState.current, current < viewState.choices.count - 1 else { return }
59+
guard let currentLine = viewState.line(forChoiceIndex: current) else {
60+
fatalError()
61+
}
62+
63+
outputCode(.setCursorHidden(true))
64+
defer { outputCode(.setCursorHidden(false)) }
65+
66+
outputCode(.moveCursor(x: 0, y: currentLine))
67+
print(" ")
68+
69+
if currentLine < viewState.height - 4 || !viewState.canScrollDown {
70+
outputCode(.moveCursor(x: 0, y: currentLine + 1))
71+
print(">")
72+
viewState.moveDown()
73+
outputCode(.moveBottom(viewState: viewState))
74+
} else {
75+
outputCode(.moveCursor(x: 0, y: 0))
76+
outputCode(.clearLine)
77+
outputCode(.moveBottom(viewState: viewState))
78+
outputCode(.moveCursorUp(n: 1))
79+
outputCode(.scrollUp(1))
80+
81+
viewState.moveDown()
82+
viewState.scrollDown()
83+
84+
print(" ", viewState.choices[viewState.visibleLines.upperBound], separator: "")
85+
86+
guard let newCurrentLine = viewState.line(forChoiceIndex: current + 1) else { fatalError() }
87+
88+
outputCode(.moveCursor(x: 0, y: newCurrentLine))
89+
print("> ")
90+
91+
outputCode(.moveBottom(viewState: viewState))
92+
}
93+
}
94+
95+
@main
96+
struct Fzf {
97+
static func main() throws -> Void {
98+
let terminalSize = TerminalSize.current()
99+
debug("----------------------------------------", reset: true)
100+
debug("Terminal height: \(terminalSize.height)")
101+
guard let tty = TTY(fileHandle: STDIN_FILENO) else {
102+
// TODO: error
103+
return
104+
}
105+
106+
let lastLine = 20
107+
let viewState = ViewState(
108+
choices: (0 ... lastLine).map { "line\($0)" },
109+
height: terminalSize.height,
110+
maxWidth: terminalSize.width - 3,
111+
visibleLines: max(lastLine - terminalSize.height + 2, 0) ... (lastLine)
112+
)
113+
114+
debug("Visible lines: \(viewState.visibleLines)")
115+
try fillScreen(viewState: viewState)
116+
while true {
117+
let key = try tty.withRawMode { () -> TerminalKey? in
118+
var buffer = [UInt8](repeating: 0, count: 3)
119+
let bytesRead = read(STDIN_FILENO, &buffer, 3)
120+
if bytesRead == 3 && buffer[0] == 0x1B && buffer[1] == 0x5B {
121+
switch buffer[2] {
122+
case 0x41: return .up
123+
case 0x42: return .down
124+
default: return nil
125+
}
126+
}
127+
return nil
128+
}
129+
guard let key else { break }
130+
switch key {
131+
case .down: moveDown(viewState: viewState)
132+
case .up: moveUp(viewState: viewState)
133+
}
134+
}
135+
}
136+
}
137+
138+
private func debug(_ message: String, reset: Bool = false) {
139+
let fh = FileHandle(forUpdatingAtPath: "/tmp/swiftfzfdebug.log")!
140+
if reset {
141+
try! fh.truncate(atOffset: 0)
142+
}
143+
if message.isEmpty { return }
144+
145+
try! fh.seekToEnd()
146+
try! fh.write(contentsOf: Data(message.utf8))
147+
try! fh.write(contentsOf: Data("\n".utf8))
148+
try! fh.close()
149+
}

Sources/TTY.swift

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//
2+
// TTY.swift
3+
// swiftfzf
4+
//
5+
// Created by Juri Pakaste on 3.11.2024.
6+
//
7+
8+
import Foundation
9+
10+
struct TTY {
11+
private let fileHandle: Int32
12+
13+
init?(fileHandle: Int32) {
14+
guard isatty(fileHandle) == 1 else { return nil }
15+
self.fileHandle = fileHandle
16+
}
17+
18+
func withRawMode<T>(body: () throws -> T) throws -> T {
19+
var originalTermios = termios()
20+
21+
if tcgetattr(fileHandle, &originalTermios) == -1 {
22+
throw Failure.getAttributes
23+
}
24+
25+
defer {
26+
_ = tcsetattr(self.fileHandle, TCSAFLUSH, &originalTermios)
27+
}
28+
29+
var raw = originalTermios
30+
31+
raw.c_iflag &= ~tcflag_t(BRKINT | ICRNL | INPCK | ISTRIP | IXON)
32+
raw.c_oflag &= ~tcflag_t(OPOST)
33+
raw.c_cflag |= tcflag_t(CS8)
34+
raw.c_lflag &= ~tcflag_t(ECHO | ICANON | IEXTEN | ISIG)
35+
36+
withUnsafeMutablePointer(to: &raw.c_cc) {
37+
$0.withMemoryRebound(to: cc_t.self, capacity: Int(NCCS)) { $0[Int(VMIN)] = 1 }
38+
}
39+
40+
if tcsetattr(fileHandle, Int32(TCSAFLUSH), &raw) < 0 {
41+
throw Failure.setAttributes
42+
}
43+
44+
return try body()
45+
}
46+
}
47+
48+
extension TTY {
49+
enum Failure: Error {
50+
case getAttributes
51+
case setAttributes
52+
}
53+
}

Sources/TerminalKey.swift

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// TerminalKey.swift
3+
// swiftfzf
4+
//
5+
// Created by Juri Pakaste on 3.11.2024.
6+
//
7+
8+
9+
enum TerminalKey {
10+
case down
11+
case up
12+
}
13+

Sources/TerminalSize.swift

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//
2+
// TerminalSize.swift
3+
// swiftfzf
4+
//
5+
// Created by Juri Pakaste on 3.11.2024.
6+
//
7+
8+
import Foundation
9+
10+
struct TerminalSize {
11+
var height: Int
12+
var width: Int
13+
}
14+
15+
extension TerminalSize {
16+
static func current() -> Self {
17+
var w = winsize()
18+
_ = ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &w);
19+
return TerminalSize(height: Int(w.ws_row), width: Int(w.ws_col))
20+
}
21+
}

Sources/ViewState.swift

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
//
2+
// ViewState.swift
3+
// swiftfzf
4+
//
5+
// Created by Juri Pakaste on 3.11.2024.
6+
//
7+
8+
final class ViewState<T: CustomStringConvertible> {
9+
let choices: [T]
10+
let height: Int
11+
12+
var current: Int?
13+
var visibleLines: ClosedRange<Int>
14+
15+
init(
16+
choices: [T],
17+
height: Int,
18+
maxWidth: Int,
19+
visibleLines: ClosedRange<Int>
20+
) {
21+
self.choices = choices
22+
self.current = choices.isEmpty ? nil : choices.count - 1
23+
self.height = height
24+
self.visibleLines = visibleLines
25+
}
26+
27+
func moveUp() {
28+
guard let current = self.current else { return }
29+
self.current = max(current - 1, 0)
30+
}
31+
32+
func scrollUp() {
33+
let visibleLines = self.visibleLines
34+
guard visibleLines.lowerBound > 0 else { return }
35+
self.visibleLines = (visibleLines.lowerBound - 1) ... (visibleLines.upperBound - 1)
36+
}
37+
38+
var canScrollUp: Bool {
39+
self.visibleLines.lowerBound > 0
40+
}
41+
42+
func moveDown() {
43+
guard let current = self.current else { return }
44+
self.current = min(current + 1, self.choices.count - 1)
45+
}
46+
47+
func scrollDown() {
48+
let visibleLines = self.visibleLines
49+
guard visibleLines.upperBound < self.choices.count - 1 else { return }
50+
self.visibleLines = (visibleLines.lowerBound + 1) ... (visibleLines.upperBound + 1)
51+
}
52+
53+
var canScrollDown: Bool {
54+
self.visibleLines.upperBound < self.choices.count - 1
55+
}
56+
57+
func line(forChoiceIndex index: Int) -> Int? {
58+
guard self.visibleLines.contains(index) else {
59+
return nil
60+
}
61+
return max(0, (self.height - self.visibleLines.count)) + index - self.visibleLines.lowerBound - 1
62+
}
63+
}
64+

0 commit comments

Comments
 (0)