Skip to content

Instantly share code, notes, and snippets.

@kiasaki
Created April 28, 2014 14:09
Show Gist options
  • Save kiasaki/11373266 to your computer and use it in GitHub Desktop.
Save kiasaki/11373266 to your computer and use it in GitHub Desktop.
Terminal Keynote Presentation
#!/usr/bin/env ruby
# encoding: utf-8
# Original project: https://github.com/fxn/tkn
Encoding.default_internal = "UTF-8"
require 'io/console'
require 'active_support/core_ext/string/strip'
require 'pygments'
#
# --- DSL -------------------------------------------------------------
#
def center(content)
slide(content, :center)
end
def block(content)
slide(content, :block)
end
def code(content, lexer=:ruby)
slide(content, :code, lexer)
end
def image(image_path)
slide(image_path, :image)
end
def section(title)
slide(title, :section)
yield
end
def slide(content, format, *args)
$slides << {
content: content.strip_heredoc,
format: format,
args: args
}
end
#
# --- ANSI Escape Sequences -------------------------------------------
#
# Clears the screen and leaves the cursor at the top left corner.
def clear_screen
"\e[2J\e[H"
end
# Clears the current line.
def clear_line
"\e[K"
end
# Puts the cursor at (row, col), 1-based.
#
# Note that characters start to get printed where the cursor is. So, to leave
# a left margin of 8 characters you want col to be 9.
def cursor_at(row, col)
"\e[#{row};#{col}H"
end
def i(str)
"\e[3m#{str}\e[0m"
end
def b(str)
"\e[1m#{str}\e[0m"
end
#
# --- Utilities -------------------------------------------------------
#
# Returns the width of the content, defined as the maximum length of its lines
# discarding trailing newlines if present.
def width(content)
content.each_line.map do |line|
ansi_length(line.chomp)
end.max
end
# Quick hack to remove ANSI escape sequences from a string. This only supports a
# handful of them, the ones that I want to use.
def ansi_strip(str)
str.gsub(/\e\[(2J|\d*(;\d+)*(m|f|H))/, '')
end
# Computes the length of a string ignoring ANSI escape sequences.
def ansi_length(str)
ansi_strip(str).length
end
# Returns the number of rows and columns of the terminal as an array of two
# integers [rows, cols].
def winsize
$stdout.winsize
end
# Sets an image as terminal background, assumes the presentation run in iTerm2.
def set_background_image(image_path)
system %(osascript -e 'tell application "iTerm" to set background image path of current session of current terminal to "#{image_path}"')
end
# Clears the background image.
def clear_background_image
set_background_image(nil)
end
# Is this slide an image? (procedural style).
def image?(slide)
slide && slide[:format] == :image
end
def clear_slide(slide)
clear_background_image if image?(slide)
print clear_screen
end
#
# --- Slide Rendering -------------------------------------------------
#
# Returns a string that the caller has to print as is to get the slide
# properly rendered. The caller is responsible for clearing the screen.
def render(slide)
send("render_#{slide[:format]}", slide[:content], *slide[:args])
end
# Renders the content by centering each individual line.
def render_center(content)
nrows, ncols = winsize
''.tap do |str|
nlines = content.count("\n")
row = [1, 1 + (nrows - nlines)/2].max
content.each_line.with_index do |line, i|
col = [1, 1 + (ncols - ansi_length(line.chomp))/2].max
str << cursor_at(row + i, col) + line
end
end
end
# Renders a section banner.
def render_section(title)
nrows, ncols = winsize
width = width(title)
rfil = [1, width - 5].max/2
lfil = [1, width - 5 - rfil].max
fleuron = '─' * lfil + ' ❧❦☙ ' + '─' * rfil
render_center("#{fleuron}\n\n#{title}\n\n#{fleuron}\n")
end
# Renders source code.
def render_code(code, lexer)
render_block(Pygments.highlight(code, formatter: 'terminal256', lexer: lexer, options: {style: 'bw'}))
end
# Centers the whole content as a block. That is, the format within the content
# is preserved, but the whole thing looks centered in the terminal. I think
# this looks nicer than an ordinary flush against the left margin.
def render_block(content)
nrows, ncols = winsize
nlines = content.count("\n")
row = [1, 1 + (nrows - nlines)/2].max
width = width(content)
col = [1, 1 + (ncols - width)/2].max
content.gsub(/^/) do
cursor_at(row, col).tap { row += 1 }
end
end
# Renders an image in the terminal by setting it as background (only works with iTerm2).
def render_image(image_path)
set_background_image(File.expand_path(image_path))
end
#
# --- Commands --------------------------------------------------------
#
def prompt(prompt)
nrows, _ = winsize
print cursor_at(nrows, 1), clear_line, prompt, ': '
$stdin.gets.strip
end
def go_to(n)
target = prompt('Go to')
n = target.to_i - 1 if target.length > 0
n
end
def search_for(n)
target = prompt('Search for')
return n if target.length == 0
regexp = %r{#{target}}
$slides.each.with_index do |slide, i|
next if image?(slide)
n = i and break if ansi_strip(slide[:content]) =~ regexp
end
n
end
def display_status(n, total)
nrows, ncols = winsize
status = "#{n + 1}/#{total}"
row = nrows
col = [1, 1 + (ncols - status.length)/2].max
print cursor_at(row, col), status
end
def hide_status
nrows, _ = winsize
print cursor_at(nrows, 1), clear_line
end
def toggle_status(status, n, total)
if status
hide_status
else
display_status(n, total)
end
!status
end
#
# --- Main Loop -------------------------------------------------------
#
# Reads either one single character or PageDown or PageUp. You need to
# configure Terminal.app so that PageDown and PageUp get passed down the
# script. Echoing is turned off while doing this.
def read_command
$stdin.noecho do |noecho|
noecho.raw do |raw|
raw.getc.tap do |command|
# Consume PageUp or PageDown if present. No other ANSI escape sequence is
# supported so a blind 3.times getc is enough.
3.times { command << raw.getc } if command == "\e"
end
end
end
end
n = 0
deck = ARGV[0]
mtime = nil
slide = nil
status = false
loop do
clear_slide(slide)
current_mtime = File.mtime(deck)
if mtime != current_mtime
$slides = []
load deck
mtime = current_mtime
end
n = [[0, n].max, $slides.length - 1].min
slide = $slides[n]
display_status(n, $slides.length) if status
if image?(slide)
render(slide)
else
render(slide).each_char do |c|
print c
sleep 0.002 # old-school touch: running cursor
end
end
case read_command
when ' ', 'n', 'l', 'k', "\e[5~"
n += 1
when 'b', 'p', 'h', 'j', "\e[6~"
n -= 1
when '^'
n = 0
when '$'
n = $slides.length - 1
when 'g'
n = go_to(n)
when '/'
n = search_for(n)
when 's'
status = toggle_status(status, n, $slides.size)
when 'q'
clear_slide(slide)
exit
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment