Slide 1

Slide 1 text

Delightful Swift CLI apps @polpielladev

Slide 2

Slide 2 text

Hi! I’m Pol Senior software engineer at the BBC

Slide 3

Slide 3 text

Tooling + Swift ❤ • Familiar language • Low memory footprint • Type safety • Mature ecosystem • Great open-source community • Build for multiple platforms

Slide 4

Slide 4 text

🤩 The beauty of Swift https://github.com/icanzilb/timeui

Slide 5

Slide 5 text

Some great examples built with Swift of course! ❤ • Sourcery - https://github.com/krzysztofzablocki/Sourcery • SwiftLint - https://github.com/realm/SwiftLint • Tuist - https://github.com/tuist/tuist • SwiftGen - https://github.com/SwiftGen/SwiftGen • Publish CLI - https://github.com/JohnSundell/Publish • Vapor toolbox - https://github.com/vapor/toolbox

Slide 6

Slide 6 text

import ArgumentParser @main struct Repeat: AsyncParsableCommand { @Flag(help: "Include a counter with each repetition.") var includeCounter = false @Option(name: .shortAndLong, help: "The number of times to repeat 'phrase'.") var count: Int? = nil @Argument(help: "The phrase to repeat.") var phrase: String func run() async throws { let repeatCount = count ?? 2 for i in 1 ... repeatCount { if includeCounter { await someAsyncOperation() } else { print(phrase) } } } } https://github.com/apple/swift-argument-parser

Slide 7

Slide 7 text

import ArgumentParser @main struct Repeat: AsyncParsableCommand { @Flag(help: "Include a counter with each repetition.") var includeCounter = false @Option(name: .shortAndLong, help: "The number of times to repeat 'phrase'.") var count: Int? = nil @Argument(help: "The phrase to repeat.") var phrase: String func run() async throws { let repeatCount = count ?? 2 for i in 1 ... repeatCount { if includeCounter { await someAsyncOperation() } else { print(phrase) } } } } https://github.com/apple/swift-argument-parser

Slide 8

Slide 8 text

import ArgumentParser @main struct Repeat: AsyncParsableCommand { @Flag(help: "Include a counter with each repetition.") var includeCounter = false @Option(name: .shortAndLong, help: "The number of times to repeat 'phrase'.") var count: Int? = nil @Argument(help: "The phrase to repeat.") var phrase: String func run() async throws { let repeatCount = count ?? 2 for i in 1 ... repeatCount { if includeCounter { await someAsyncOperation() } else { print(phrase) } } } } https://github.com/apple/swift-argument-parser

Slide 9

Slide 9 text

No content

Slide 10

Slide 10 text

✨ Swift on Linux! https://github.com/keith/swiftpm-linux-cross

Slide 11

Slide 11 text

🤩 Now easier? https://github.com/swift-server/swiftly

Slide 12

Slide 12 text

🚀 Automating releases! https://www.polpiella.dev/automating-swift-package-releases-with-github-actions

Slide 13

Slide 13 text

What is missing though?

Slide 14

Slide 14 text

Making tooling that looks great is hard… • UX Developer experience is not great • Other languages and ecosystems are more advanced • Javascript is a great example! • We need to aim for delightful! ✨

Slide 15

Slide 15 text

No content

Slide 16

Slide 16 text

No content

Slide 17

Slide 17 text

The ecosystem allows for delightful • Ora - https://github.com/sindresorhus/ora • Chalk - https://github.com/chalk/chalk • Prompts - https://github.com/terkelg/prompts • Commander - https://github.com/tj/commander.js • Clack - https://github.com/natemoo-re/clack

Slide 18

Slide 18 text

Swift has some tools like that… • Chalk - https://github.com/mxcl/Chalk • Commander - https://github.com/kylef/Commander • TermKit - https://github.com/migueldeicaza/TermKit • ANSITerminal - https://github.com/pakLebah/ANSITerminal

Slide 19

Slide 19 text

But how do you do this?

Slide 20

Slide 20 text

ANSI escape sequences • String sequences • Perform operations in the terminal • Control the cursor’s position, change colour of text, etc. • Supported by Unix operating systems • Support from Windows 10

Slide 21

Slide 21 text

Modifying text print("Hello, world!") "Hello, world!" Output

Slide 22

Slide 22 text

Modifying text print("\u{1B}[31mHello, world!") "Hello, world!" Output Start Sequence Red colour

Slide 23

Slide 23 text

Modifying text print("\u{1B}[1;31mHello, world!") "Hello, world!" Output

Slide 24

Slide 24 text

Don’t give up just yet… https://github.com/pakLebah/ANSITerminal

Slide 25

Slide 25 text

Modifying text print("Hello, world!") "Hello, world!" Output

Slide 26

Slide 26 text

Modifying text import ANSITerminal print("Hello, world!") "Hello, world!" Output

Slide 27

Slide 27 text

Modifying text "Hello, world!" Output import ANSITerminal print("Hello, world!".red)

Slide 28

Slide 28 text

Modifying text "Hello, world!" Output import ANSITerminal print("Hello, world!".red.bold)

Slide 29

Slide 29 text

"Hello, world!" ▋ Output Moving the cursor print("Hello World!")

Slide 30

Slide 30 text

"Hello, world!"▋ Output Moving the cursor import ANSITerminal write("Hello World!")

Slide 31

Slide 31 text

"Hello, world!"▋ Output Moving the cursor import ANSITerminal let startPosition = readCursorPos() write("Hello World!")

Slide 32

Slide 32 text

▋"Hello, world!" Output Moving the cursor import ANSITerminal let startPosition = readCursorPos() write("Hello World!")

Slide 33

Slide 33 text

▋"Hello, world!" Output Moving the cursor import ANSITerminal let startPosition = readCursorPos() write("Hello World!") moveTo(startPosition.row, startPosition.col)

Slide 34

Slide 34 text

▋"Hello, world!" Output Moving the cursor import ANSITerminal let startPosition = readCursorPos() write("Hello World!".foreColor(244)) moveTo(startPosition.row, startPosition.col)

Slide 35

Slide 35 text

"Hello▋ world!" Output Moving the cursor import ANSITerminal let startPosition = readCursorPos() write("Hello World!".foreColor(244)) moveTo(startPosition.row, startPosition.col) write("Hello")

Slide 36

Slide 36 text

⚠ Something to consider

Slide 37

Slide 37 text

Someone, probably all the time! “It’s not you, it’s Xcode!”

Slide 38

Slide 38 text

🤩 The terminal is your BFF

Slide 39

Slide 39 text

An interactive component

Slide 40

Slide 40 text

I’ve been porting Clack to Swift https://github.com/polpielladev/clack-swift

Slide 41

Slide 41 text

Let’s build a Text Input component • Custom text entry • Placeholder • Validation • Support for secure text entry • Headless + UI

Slide 42

Slide 42 text

Headless first

Slide 43

Slide 43 text

import Foundation import ANSITerminal func readTextInput( validate: (String) -> Bool = { _ in true }, validationFailed: () -> Void = {}, onNewCharacter: (Character) -> Void, onDelete: (Int, Int) -> Void, removePlaceholder: () -> Void, showPlaceholder: () -> Void ) - > String { }

Slide 44

Slide 44 text

import Foundation import ANSITerminal func readTextInput( validate: (String) -> Bool = { _ in true }, validationFailed: () -> Void = {}, onNewCharacter: (Character) -> Void, onDelete: (Int, Int) -> Void, removePlaceholder: () -> Void, showPlaceholder: () -> Void ) - > String { }

Slide 45

Slide 45 text

import Foundation import ANSITerminal func readTextInput( validate: (String) -> Bool = { _ in true }, validationFailed: () -> Void = {}, onNewCharacter: (Character) -> Void, onDelete: (Int, Int) -> Void, removePlaceholder: () -> Void, showPlaceholder: () -> Void ) - > String { }

Slide 46

Slide 46 text

import Foundation import ANSITerminal func readTextInput( validate: (String) -> Bool = { _ in true }, validationFailed: () -> Void = {}, onNewCharacter: (Character) -> Void, onDelete: (Int, Int) -> Void, removePlaceholder: () -> Void, showPlaceholder: () -> Void ) - > String { }

Slide 47

Slide 47 text

var output = "" while true { clearBuffer() if keyPressed() { let char = readChar() if char == NonPrintableChar.enter.char() { if validate(output) { break } else { validationFailed() } } else if char == NonPrintableChar.del.char() { let cursorPosition = readCursorPos() if output.count > 0 { onDelete(cursorPosition.row, cursorPosition.col - 1) _ = output.removeLast() } if output.count == 0 { showPlaceholder() } } if !isNonPrintable(char: char) { if output.isEmpty { removePlaceholder() } onNewCharacter(char) output.append(char) } } } return output

Slide 48

Slide 48 text

var output = "" while true { clearBuffer() if keyPressed() { let char = readChar() if char == NonPrintableChar.enter.char() { if validate(output) { break } else { validationFailed() } } else if char == NonPrintableChar.del.char() { let cursorPosition = readCursorPos() if output.count > 0 { onDelete(cursorPosition.row, cursorPosition.col - 1) _ = output.removeLast() } if output.count == 0 { showPlaceholder() } } if !isNonPrintable(char: char) { if output.isEmpty { removePlaceholder() } onNewCharacter(char) output.append(char) } } } return output

Slide 49

Slide 49 text

var output = "" while true { clearBuffer() if keyPressed() { let char = readChar() if char == NonPrintableChar.enter.char() { if validate(output) { break } else { validationFailed() } } else if char == NonPrintableChar.del.char() { let cursorPosition = readCursorPos() if output.count > 0 { onDelete(cursorPosition.row, cursorPosition.col - 1) _ = output.removeLast() } if output.count == 0 { showPlaceholder() } } if !isNonPrintable(char: char) { if output.isEmpty { removePlaceholder() } onNewCharacter(char) output.append(char) } } } return output

Slide 50

Slide 50 text

var output = "" while true { clearBuffer() if keyPressed() { let char = readChar() if char == NonPrintableChar.enter.char() { if validate(output) { break } else { validationFailed() } } else if char == NonPrintableChar.del.char() { let cursorPosition = readCursorPos() if output.count > 0 { onDelete(cursorPosition.row, cursorPosition.col - 1) _ = output.removeLast() } if output.count == 0 { showPlaceholder() } } if !isNonPrintable(char: char) { if output.isEmpty { removePlaceholder() } onNewCharacter(char) output.append(char) } } } return output

Slide 51

Slide 51 text

var output = "" while true { clearBuffer() if keyPressed() { let char = readChar() if char == NonPrintableChar.enter.char() { if validate(output) { break } else { validationFailed() } } else if char == NonPrintableChar.del.char() { let cursorPosition = readCursorPos() if output.count > 0 { onDelete(cursorPosition.row, cursorPosition.col - 1) _ = output.removeLast() } if output.count == 0 { showPlaceholder() } } if !isNonPrintable(char: char) { if output.isEmpty { removePlaceholder() } onNewCharacter(char) output.append(char) } } } return output

Slide 52

Slide 52 text

var output = "" while true { clearBuffer() if keyPressed() { let char = readChar() if char == NonPrintableChar.enter.char() { if validate(output) { break } else { validationFailed() } } else if char == NonPrintableChar.del.char() { let cursorPosition = readCursorPos() if output.count > 0 { onDelete(cursorPosition.row, cursorPosition.col - 1) _ = output.removeLast() } if output.count == 0 { showPlaceholder() } } if !isNonPrintable(char: char) { if output.isEmpty { removePlaceholder() } onNewCharacter(char) output.append(char) } } } return output

Slide 53

Slide 53 text

var output = "" while true { clearBuffer() if keyPressed() { let char = readChar() if char == NonPrintableChar.enter.char() { if validate(output) { break } else { validationFailed() } } else if char == NonPrintableChar.del.char() { let cursorPosition = readCursorPos() if output.count > 0 { onDelete(cursorPosition.row, cursorPosition.col - 1) _ = output.removeLast() } if output.count == 0 { showPlaceholder() } } if !isNonPrintable(char: char) { if output.isEmpty { removePlaceholder() } onNewCharacter(char) output.append(char) } } } return output

Slide 54

Slide 54 text

Adding the head!

Slide 55

Slide 55 text

let textInput = readTextInput(validate: { !$0.isEmpty }, validationFailed: { validationFailed = true let currentPosition = readCursorPos() writeAt(promptStartLine, 0, ANSIChar.warn) updateBracketColor(fromLine: promptStartLine, toLine: bottomPos.row, withColor: 11) writeAt(bottomPos.row, bottomPos.col + 1, validator ?. failureString ?? "") moveTo(currentPosition.row, currentPosition.col) }, onNewCharacter: { char in if validationFailed { let currentPosition = readCursorPos() writeAt(promptStartLine, 0, "◆".foreColor(81).bold) updateBracketColor(fromLine: promptStartLine, toLine: bottomPos.row, withColor: 81) moveTo(bottomPos.row, bottomPos.col + 1) clearToEndOfLine() moveTo(currentPosition.row, currentPosition.col) validationFailed = false } write("\(isSecureEntry ? "▪" : char)") }, onDelete: { row, col in moveTo(row, col); deleteChar() }, removePlaceholder: { moveTo(initialCursorPosition.row, initialCursorPosition.col) clearToEndOfLine() }, showPlaceholder: { write(placeholder.foreColor(244)) moveTo(initialCursorPosition.row, initialCursorPosition.col) })

Slide 56

Slide 56 text

let textInput = readTextInput(validate: { !$0.isEmpty }, validationFailed: { validationFailed = true let currentPosition = readCursorPos() writeAt(promptStartLine, 0, ANSIChar.warn) updateBracketColor(fromLine: promptStartLine, toLine: bottomPos.row, withColor: 11) writeAt(bottomPos.row, bottomPos.col + 1, validator ?. failureString ?? "") moveTo(currentPosition.row, currentPosition.col) }, onNewCharacter: { char in if validationFailed { let currentPosition = readCursorPos() writeAt(promptStartLine, 0, "◆".foreColor(81).bold) updateBracketColor(fromLine: promptStartLine, toLine: bottomPos.row, withColor: 81) moveTo(bottomPos.row, bottomPos.col + 1) clearToEndOfLine() moveTo(currentPosition.row, currentPosition.col) validationFailed = false } write("\(isSecureEntry ? "▪" : char)") }, onDelete: { row, col in moveTo(row, col); deleteChar() }, removePlaceholder: { moveTo(initialCursorPosition.row, initialCursorPosition.col) clearToEndOfLine() }, showPlaceholder: { write(placeholder.foreColor(244)) moveTo(initialCursorPosition.row, initialCursorPosition.col) })

Slide 57

Slide 57 text

let textInput = readTextInput(validate: { !$0.isEmpty }, validationFailed: { validationFailed = true let currentPosition = readCursorPos() writeAt(promptStartLine, 0, ANSIChar.warn) updateBracketColor(fromLine: promptStartLine, toLine: bottomPos.row, withColor: 11) writeAt(bottomPos.row, bottomPos.col + 1, validator ?. failureString ?? "") moveTo(currentPosition.row, currentPosition.col) }, onNewCharacter: { char in if validationFailed { let currentPosition = readCursorPos() writeAt(promptStartLine, 0, "◆".foreColor(81).bold) updateBracketColor(fromLine: promptStartLine, toLine: bottomPos.row, withColor: 81) moveTo(bottomPos.row, bottomPos.col + 1) clearToEndOfLine() moveTo(currentPosition.row, currentPosition.col) validationFailed = false } write("\(isSecureEntry ? "▪" : char)") }, onDelete: { row, col in moveTo(row, col); deleteChar() }, removePlaceholder: { moveTo(initialCursorPosition.row, initialCursorPosition.col) clearToEndOfLine() }, showPlaceholder: { write(placeholder.foreColor(244)) moveTo(initialCursorPosition.row, initialCursorPosition.col) })

Slide 58

Slide 58 text

let textInput = readTextInput(validate: { !$0.isEmpty }, validationFailed: { validationFailed = true let currentPosition = readCursorPos() writeAt(promptStartLine, 0, ANSIChar.warn) updateBracketColor(fromLine: promptStartLine, toLine: bottomPos.row, withColor: 11) writeAt(bottomPos.row, bottomPos.col + 1, validator ?. failureString ?? "") moveTo(currentPosition.row, currentPosition.col) }, onNewCharacter: { char in if validationFailed { let currentPosition = readCursorPos() writeAt(promptStartLine, 0, "◆".foreColor(81).bold) updateBracketColor(fromLine: promptStartLine, toLine: bottomPos.row, withColor: 81) moveTo(bottomPos.row, bottomPos.col + 1) clearToEndOfLine() moveTo(currentPosition.row, currentPosition.col) validationFailed = false } write("\(isSecureEntry ? "▪" : char)") }, onDelete: { row, col in moveTo(row, col); deleteChar() }, removePlaceholder: { moveTo(initialCursorPosition.row, initialCursorPosition.col) clearToEndOfLine() }, showPlaceholder: { write(placeholder.foreColor(244)) moveTo(initialCursorPosition.row, initialCursorPosition.col) })

Slide 59

Slide 59 text

let textInput = readTextInput(validate: { !$0.isEmpty }, validationFailed: { validationFailed = true let currentPosition = readCursorPos() writeAt(promptStartLine, 0, ANSIChar.warn) updateBracketColor(fromLine: promptStartLine, toLine: bottomPos.row, withColor: 11) writeAt(bottomPos.row, bottomPos.col + 1, validator ?. failureString ?? "") moveTo(currentPosition.row, currentPosition.col) }, onNewCharacter: { char in if validationFailed { let currentPosition = readCursorPos() writeAt(promptStartLine, 0, "◆".foreColor(81).bold) updateBracketColor(fromLine: promptStartLine, toLine: bottomPos.row, withColor: 81) moveTo(bottomPos.row, bottomPos.col + 1) clearToEndOfLine() moveTo(currentPosition.row, currentPosition.col) validationFailed = false } write("\(isSecureEntry ? "▪" : char)") }, onDelete: { row, col in moveTo(row, col); deleteChar() }, removePlaceholder: { moveTo(initialCursorPosition.row, initialCursorPosition.col) clearToEndOfLine() }, showPlaceholder: { write(placeholder.foreColor(244)) moveTo(initialCursorPosition.row, initialCursorPosition.col) })

Slide 60

Slide 60 text

let textInput = readTextInput(validate: { !$0.isEmpty }, validationFailed: { validationFailed = true let currentPosition = readCursorPos() writeAt(promptStartLine, 0, ANSIChar.warn) updateBracketColor(fromLine: promptStartLine, toLine: bottomPos.row, withColor: 11) writeAt(bottomPos.row, bottomPos.col + 1, validator ?. failureString ?? "") moveTo(currentPosition.row, currentPosition.col) }, onNewCharacter: { char in if validationFailed { let currentPosition = readCursorPos() writeAt(promptStartLine, 0, "◆".foreColor(81).bold) updateBracketColor(fromLine: promptStartLine, toLine: bottomPos.row, withColor: 81) moveTo(bottomPos.row, bottomPos.col + 1) clearToEndOfLine() moveTo(currentPosition.row, currentPosition.col) validationFailed = false } write("\(isSecureEntry ? "▪" : char)") }, onDelete: { row, col in moveTo(row, col); deleteChar() }, removePlaceholder: { moveTo(initialCursorPosition.row, initialCursorPosition.col) clearToEndOfLine() }, showPlaceholder: { write(placeholder.foreColor(244)) moveTo(initialCursorPosition.row, initialCursorPosition.col) })

Slide 61

Slide 61 text

let textInput = readTextInput(validate: { !$0.isEmpty }, validationFailed: { validationFailed = true let currentPosition = readCursorPos() writeAt(promptStartLine, 0, ANSIChar.warn) updateBracketColor(fromLine: promptStartLine, toLine: bottomPos.row, withColor: 11) writeAt(bottomPos.row, bottomPos.col + 1, validator ?. failureString ?? "") moveTo(currentPosition.row, currentPosition.col) }, onNewCharacter: { char in if validationFailed { let currentPosition = readCursorPos() writeAt(promptStartLine, 0, "◆".foreColor(81).bold) updateBracketColor(fromLine: promptStartLine, toLine: bottomPos.row, withColor: 81) moveTo(bottomPos.row, bottomPos.col + 1) clearToEndOfLine() moveTo(currentPosition.row, currentPosition.col) validationFailed = false } write("\(isSecureEntry ? "▪" : char)") }, onDelete: { row, col in moveTo(row, col); deleteChar() }, removePlaceholder: { moveTo(initialCursorPosition.row, initialCursorPosition.col) clearToEndOfLine() }, showPlaceholder: { write("sk- ... ".foreColor(244)) moveTo(initialCursorPosition.row, initialCursorPosition.col) })

Slide 62

Slide 62 text

🤯 That’s literally it!

Slide 63

Slide 63 text

💪 Leverage the power of Unicode!

Slide 64

Slide 64 text

Ora’s spinners are made of braille pattern characters

Slide 65

Slide 65 text

clack-swift is open source! https://github.com/polpielladev/clack-swift

Slide 66

Slide 66 text

An example

Slide 67

Slide 67 text

No content

Slide 68

Slide 68 text

struct Auth: AsyncParsableCommand { func run() async throws { intro(title: "Hi! Set your token to use Chatty!") let validator = Validator( validate: { !$0.isEmpty }, failureString: "The OpenAI API token can not be empty ... " ) let token = text( question: "Enter your OpenAPI token", placeholder: "sk- ... ", validator: validator, isSecureEntry: true ) let keychain = Keychain(service: "dev.polpiella.chatty") keychain["openaitoken"] = token outro( text: "You're all set! You can now run " + "`chatty`".magenta.onWhite + " to converse ... " ) } }

Slide 69

Slide 69 text

struct Auth: AsyncParsableCommand { func run() async throws { intro(title: "Hi! 👋 Set your token to use Chatty!") let validator = Validator( validate: { !$0.isEmpty }, failureString: "The OpenAI API token can not be empty ... " ) let token = text( question: "Enter your OpenAPI token", placeholder: "sk- ... ", validator: validator, isSecureEntry: true ) let keychain = Keychain(service: "dev.polpiella.chatty") keychain["openaitoken"] = token outro( text: "You're all set! You can now run " + "`chatty`".magenta.onWhite + " to converse ... " ) } }

Slide 70

Slide 70 text

struct Auth: AsyncParsableCommand { func run() async throws { intro(title: "Hi! 👋 Set your token to use Chatty!") let validator = Validator( validate: { !$0.isEmpty }, failureString: "The OpenAI API token can not be empty ... " ) let token = text( question: "Enter your OpenAPI token", placeholder: "sk- ... ", validator: validator, isSecureEntry: true ) let keychain = Keychain(service: "dev.polpiella.chatty") keychain["openaitoken"] = token outro( text: "You're all set! You can now run " + "`chatty`".magenta.onWhite + " to converse ... " ) } }

Slide 71

Slide 71 text

struct Auth: AsyncParsableCommand { func run() async throws { intro(title: "Hi! 👋 Set your token to use Chatty!") let validator = Validator( validate: { !$0.isEmpty }, failureString: "The OpenAI API token can not be empty ... " ) let token = text( question: "Enter your OpenAPI token", placeholder: "sk- ... ", validator: validator, isSecureEntry: true ) let keychain = Keychain(service: "dev.polpiella.chatty") keychain["openaitoken"] = token outro( text: "You're all set! You can now run " + "`chatty`".magenta.onWhite + " to converse ... " ) } }

Slide 72

Slide 72 text

struct Auth: AsyncParsableCommand { func run() async throws { intro(title: "Hi! 👋 Set your token to use Chatty!") let validator = Validator( validate: { !$0.isEmpty }, failureString: "The OpenAI API token can not be empty ... " ) let token = text( question: "Enter your OpenAPI token", placeholder: "sk- ... ", validator: validator, isSecureEntry: true ) let keychain = Keychain(service: "dev.polpiella.chatty") keychain["openaitoken"] = token outro( text: "✅ You're all set! You can now run " + "`chatty`".magenta.onWhite + " to converse ... " ) } }

Slide 73

Slide 73 text

intro() text() outro()

Slide 74

Slide 74 text

🤩 Chatty-cli is open source! https://github.com/polpielladev/chatty-cli

Slide 75

Slide 75 text

Say hi! https://pol.link