Skip to content

Commit 2d046ed

Browse files
committed
[Basic] Add TerminalController class
1 parent fde884e commit 2d046ed

File tree

6 files changed

+426
-0
lines changed

6 files changed

+426
-0
lines changed
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright 2016 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import libc
12+
import func POSIX.getenv
13+
14+
/// A class to have better control on tty output streams: standard output and standard error.
15+
/// Allows operations like cursor movement and colored text output on tty.
16+
public final class TerminalController {
17+
18+
/// Terminal color choices.
19+
public enum Color {
20+
case noColor
21+
22+
case red
23+
case green
24+
case yellow
25+
case cyan
26+
27+
case white
28+
case black
29+
case grey
30+
31+
/// Returns the color code which can be prefixed on a string to display it in that color.
32+
fileprivate var string: String {
33+
switch self {
34+
case .noColor: return ""
35+
case .red: return "\u{001B}[31m"
36+
case .green: return "\u{001B}[32m"
37+
case .yellow: return "\u{001B}[33m"
38+
case .cyan: return "\u{001B}[36m"
39+
case .white: return "\u{001B}[37m"
40+
case .black: return "\u{001B}[30m"
41+
case .grey: return "\u{001B}[30;1m"
42+
}
43+
}
44+
}
45+
46+
/// Pointer to output stream to operate on.
47+
private var stream: LocalFileOutputByteStream
48+
49+
/// Width of the terminal.
50+
public let width: Int
51+
52+
/// Code to clear the line on a tty.
53+
private let clearLineString = "\u{001B}[2K"
54+
55+
/// Code to end any currently active wrapping.
56+
private let resetString = "\u{001B}[0m"
57+
58+
/// Code to make string bold.
59+
private let boldString = "\u{001B}[1m"
60+
61+
/// Constructs the instance if the stream is a tty.
62+
public init?(stream: LocalFileOutputByteStream) {
63+
// Make sure this file stream is tty.
64+
guard isatty(fileno(stream.fp)) != 0 else {
65+
return nil
66+
}
67+
width = TerminalController.terminalWidth() ?? 80 // Assume default if we are not able to determine.
68+
self.stream = stream
69+
}
70+
71+
/// Tries to get the terminal width first using COLUMNS env variable and
72+
/// if that fails ioctl method testing on stdout stream.
73+
///
74+
/// - Returns: Current width of terminal if it was determinable.
75+
public static func terminalWidth() -> Int? {
76+
// Try to get from enviornment.
77+
if let columns = POSIX.getenv("COLUMNS"), let width = Int(columns) {
78+
return width
79+
}
80+
81+
// Try determining using ioctl.
82+
var ws = winsize()
83+
if ioctl(1, UInt(TIOCGWINSZ), &ws) == 0 {
84+
return Int(ws.ws_col)
85+
}
86+
return nil
87+
}
88+
89+
/// Flushes the stream.
90+
public func flush() {
91+
stream.flush()
92+
}
93+
94+
/// Clears the current line and moves the cursor to beginning of the line..
95+
public func clearLine() {
96+
stream <<< clearLineString <<< "\r"
97+
flush()
98+
}
99+
100+
/// Moves the cursor y columns up.
101+
public func moveCursor(y: Int) {
102+
stream <<< "\u{001B}[\(y)A"
103+
flush()
104+
}
105+
106+
/// Writes a string to the stream.
107+
public func write(_ string: String, inColor color: Color = .noColor, bold: Bool = false) {
108+
writeWrapped(string, inColor: color, bold: bold, stream: stream)
109+
flush()
110+
}
111+
112+
/// Inserts a new line character into the stream.
113+
public func endLine() {
114+
stream <<< "\n"
115+
flush()
116+
}
117+
118+
/// Wraps the string into the color mentioned.
119+
public func wrap(_ string: String, inColor color: Color, bold: Bool = false) -> String {
120+
let stream = BufferedOutputByteStream()
121+
writeWrapped(string, inColor: color, bold: bold, stream: stream)
122+
guard let string = stream.bytes.asString else {
123+
fatalError("Couldn't get string value from stream.")
124+
}
125+
return string
126+
}
127+
128+
private func writeWrapped(_ string: String, inColor color: Color, bold: Bool = false, stream: OutputByteStream) {
129+
// Don't wrap if string is empty or color is no color.
130+
guard !string.isEmpty && color != .noColor else {
131+
stream <<< string
132+
return
133+
}
134+
stream <<< color.string <<< (bold ? boldString : "") <<< string <<< resetString
135+
}
136+
}

Sources/Utility/ProgressBar.swift

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright 2016 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Basic
12+
13+
/// A protocol to operate on terminal based progress bars.
14+
public protocol ProgressBarProtocol {
15+
func update(percent: Int, text: String)
16+
func complete()
17+
}
18+
19+
/// Simple ProgressBar which shows the update text in new lines.
20+
public final class SimpleProgressBar: ProgressBarProtocol {
21+
private let header: String
22+
private var isClear: Bool
23+
private var stream: OutputByteStream
24+
25+
init(stream: OutputByteStream, header: String) {
26+
self.stream = stream
27+
self.header = header
28+
self.isClear = true
29+
}
30+
31+
public func update(percent: Int, text: String) {
32+
if isClear {
33+
stream <<< header
34+
stream <<< "\n"
35+
stream.flush()
36+
isClear = false
37+
}
38+
39+
stream <<< "\(percent)%: " <<< text
40+
stream <<< "\n"
41+
stream.flush()
42+
}
43+
44+
public func complete() {
45+
}
46+
}
47+
48+
/// Three line progress bar with header, redraws on each update.
49+
public final class ProgressBar: ProgressBarProtocol {
50+
private let term: TerminalController
51+
private let header: String
52+
private var isClear: Bool // true if haven't drawn anything yet.
53+
54+
init(term: TerminalController, header: String) {
55+
self.term = term
56+
self.header = header
57+
self.isClear = true
58+
}
59+
60+
public func update(percent: Int, text: String) {
61+
if isClear {
62+
let spaceCount = (term.width/2 - header.utf8.count/2)
63+
term.write(" ".repeating(n: spaceCount))
64+
term.write(header, inColor: .cyan, bold: true)
65+
term.endLine()
66+
isClear = false
67+
}
68+
69+
term.clearLine()
70+
let percentString = percent < 10 ? " \(percent)" : "\(percent)"
71+
let prefix = "\(percentString)% " + term.wrap("[", inColor: .green, bold: true)
72+
term.write(prefix)
73+
74+
let barWidth = term.width - prefix.utf8.count
75+
let n = Int(Double(barWidth) * Double(percent)/100.0)
76+
77+
term.write("=".repeating(n: n) + "-".repeating(n: barWidth - n), inColor: .green)
78+
term.write("]", inColor: .green, bold: true)
79+
term.endLine()
80+
81+
term.clearLine()
82+
term.write(text)
83+
84+
term.moveCursor(y: 1)
85+
}
86+
87+
public func complete() {
88+
term.endLine()
89+
}
90+
}
91+
92+
/// Creates colored or simple progress bar based on the provided output stream.
93+
public func createProgressBar(forStream stream: OutputByteStream, header: String) -> ProgressBarProtocol {
94+
if let stdStream = stream as? LocalFileOutputByteStream, let term = TerminalController(stream: stdStream) {
95+
return ProgressBar(term: term, header: header)
96+
}
97+
return SimpleProgressBar(stream: stream, header: header)
98+
}
99+
100+
private extension String {
101+
/// Repeats self n times. If n is less than zero, returns the same string.
102+
func repeating(n: Int) -> String {
103+
guard n > 0 else { return self }
104+
var str = ""
105+
for _ in 0..<n {
106+
str = str + self
107+
}
108+
return str
109+
}
110+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright 2015 - 2016 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import XCTest
12+
@testable import Basic
13+
import libc
14+
15+
final class PseudoTerminal {
16+
let master: Int32
17+
let slave: Int32
18+
var outStream: LocalFileOutputByteStream
19+
20+
init?(){
21+
var master: Int32 = 0
22+
var slave: Int32 = 0
23+
if openpty(&master, &slave, nil, nil, nil) != 0 {
24+
return nil
25+
}
26+
guard let outStream = try? LocalFileOutputByteStream(filePointer: fdopen(slave, "w")) else {
27+
return nil
28+
}
29+
self.outStream = outStream
30+
self.master = master
31+
self.slave = slave
32+
}
33+
34+
func readMaster(maxChars n: Int = 1000) -> String? {
35+
var buf: [CChar] = [CChar](repeating: 0, count: n)
36+
if read(master, &buf, n) <= 0 {
37+
return nil
38+
}
39+
return String(cString: buf)
40+
}
41+
42+
func close() {
43+
_ = libc.close(slave)
44+
_ = libc.close(master)
45+
}
46+
}
47+
48+
final class TerminalControllerTests: XCTestCase {
49+
func testBasic() {
50+
guard let pty = PseudoTerminal(), let term = TerminalController(stream: pty.outStream) else {
51+
XCTFail("Couldn't create pseudo terminal.")
52+
return
53+
}
54+
55+
// Test red color.
56+
term.write("hello", inColor: .red)
57+
XCTAssertEqual(pty.readMaster(), "\u{1B}[31mhello\u{1B}[0m")
58+
59+
// Test clearLine.
60+
term.clearLine()
61+
XCTAssertEqual(pty.readMaster(), "\u{1B}[2K\r")
62+
63+
// Test endline.
64+
term.endLine()
65+
XCTAssertEqual(pty.readMaster(), "\r\n")
66+
67+
// Test move cursor.
68+
term.moveCursor(y: 3)
69+
XCTAssertEqual(pty.readMaster(), "\u{1B}[3A")
70+
71+
// Test color wrapping.
72+
var wrapped = term.wrap("hello", inColor: .noColor)
73+
XCTAssertEqual(wrapped, "hello")
74+
75+
wrapped = term.wrap("green", inColor: .green)
76+
XCTAssertEqual(wrapped, "\u{001B}[32mgreen\u{001B}[0m")
77+
pty.close()
78+
}
79+
80+
static var allTests = [
81+
("testBasic", testBasic),
82+
]
83+
}

Tests/BasicTests/XCTestManifests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public func allTests() -> [XCTestCaseEntry] {
3232
testCase(SyncronizedQueueTests.allTests),
3333
testCase(TOMLTests.allTests),
3434
testCase(TemporaryFileTests.allTests),
35+
testCase(TerminalControllerTests.allTests),
3536
testCase(ThreadTests.allTests),
3637
testCase(WalkTests.allTests),
3738
]

0 commit comments

Comments
 (0)