-
-
Save kiasaki/11373266 to your computer and use it in GitHub Desktop.
Terminal Keynote Presentation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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