Switch terminal theme based on location day/night
I recently switched from VS Code, Solarized, Hack and rxvt-unicode to LazyVim, TokyoNight, FiraCode and kitty.
My previous setup would auto-switch between the light and dark theme on the terminal and the editor based on the time of day for my location and whether it was day or night. I’ve found this is easier on my eyes.
I wanted to replicate this functionality with the new setup, so I came up with the following Ruby script that queries (and caches the response of) an API for the sunrise and sunset time of a specified location and symlinks the right theme file based on whether it is currently day or night.
Note: I intentionally chose not to use a Gem to calculate the sunrise/sunset time to keep the script dependency-free.
require 'fileutils'
require 'json'
require 'net/http'
require 'uri'
if ARGV.empty?
warn "Usage: LAT=<latitude_degrees> LON=<longitude_degrees> #{$0} <day_file_path> <night_file_path> <symlink_path>"
exit 1
end
API_URL_FMT = 'https://api.sunrisesunset.io/json?lat=%f&lng=%f&time_format=24'.freeze
CACHE_PATH_FMT = '/tmp/sunrise-sunset-cache-%s-%s.json'.freeze
LATITUDE = ENV.fetch('LAT').freeze # Latitude of area in degrees North
LONGITUDE = ENV.fetch('LON').freeze # Longitude of area in degrees East
DAY_PATH = ARGV.fetch(0) # Path to day theme file
NIGHT_PATH = ARGV.fetch(1) # Path to night theme file
DEST_PATH = ARGV.fetch(2) # Path to end-result theme file (symbolic link)
cache_path = format(CACHE_PATH_FMT, LATITUDE, LONGITUDE)
# Remove if outdated
if File.exist?(cache_path) && File.mtime(cache_path).to_date != Date.today
FileUtils.rm_f(cache_path)
end
data =
begin
JSON.load(File.open(json_cache_path))
rescue Errno::ENOENT
url = format(API_URL_FMT, LATITUDE, LONGITUDE)
uri = URI.parse(url)
response = Net::HTTP.get_response(uri)
File.open(json_cache_path, 'w') { |f| f.write(response.body) }
JSON.parse(response.body)
end
sunrise, sunset = data.fetch('results').values_at('dawn', 'dusk')
now = Time.now
sunrise_time = Time.new(now.year, now.month, now.day, *sunrise.split(':').map(&:to_i))
sunset_time = Time.new(now.year, now.month, now.day, *sunset.split(':').map(&:to_i))
theme_path =
if now > sunrise_time && now < sunset_time
DAY_PATH
else
NIGHT_PATH
end
FileUtils.ln_s(theme_path, DEST_PATH, force: true)
I then proceeded to rewrite it in a more object-oriented approach, with object composition and separated concerns among classes and methods (drawing inspiration from the POODR book):
require 'date'
require 'fileutils'
require 'json'
require 'net/http'
require 'uri'
module DayNight
class FileCacher
attr_reader :cache_path
def initialize(cache_path)
@cache_path = cache_path
end
private def outdated_cache?
File.exist?(cache_path) && File.mtime(cache_path).to_date != Date.today
end
private def bust_cache
FileUtils.rm_f(cache_path)
end
private def write_cache(data)
File.open(cache_path, 'w') do |f|
f.write(data)
end
end
def call
bust_cache if outdated_cache?
File.read(cache_path)
rescue Errno::ENOENT
yield.tap do |data|
write_cache(data)
end
end
end
class DataFetcher
CACHE_PATH_FMT = '/tmp/sunrise-sunset-cache-%s-%s.json'.freeze
API_URL_FMT = 'https://api.sunrisesunset.io/json?lat=%f&lng=%f&time_format=24'.freeze
attr_reader :latitude, :longitude
def initialize(latitude, longitude)
@latitude = latitude
@longitude = longitude
end
private def cache_path
format(CACHE_PATH_FMT, latitude, longitude)
end
private def url
format(API_URL_FMT, latitude, longitude)
end
private def response
uri = URI.parse(url)
Net::HTTP.get_response(uri)
end
private def data
FileCacher.new(cache_path).call do
response.body
end
end
def call
JSON.parse(data)
end
end
class Calculator
attr_reader :data, :now
def initialize(data)
@data = data
@now = Time.now
end
private def to_time(time_str)
Time.new(now.year, now.month, now.day, *time_str.split(':').map(&:to_i))
end
private def sunrise_time
to_time(data.fetch('results').fetch('dawn'))
end
private def sunset_time
to_time(data.fetch('results').fetch('dusk'))
end
def day?
sunrise_time < now && now < sunset_time
end
end
class Symlinker
attr_reader :day_path, :night_path, :dest_path
def initialize(day_path:, night_path:, dest_path:, is_day:)
@day_path = day_path
@night_path = night_path
@dest_path = dest_path
@is_day = is_day
end
def day?
@is_day
end
private def source_path
if day?
day_path
else
night_path
end
end
def call
FileUtils.ln_s(source_path, dest_path, force: true)
end
end
def self.symlink(latitude:, longitude:, day_path:, night_path:, dest_path:)
data = DataFetcher.new(latitude, longitude).call
Symlinker.new(
day_path: day_path,
night_path: night_path,
dest_path: dest_path,
is_day: Calculator.new(data).day?
).call
end
end
if ARGV.empty?
warn "Usage: LAT=<latitude_degrees> LON=<longitude_degrees> #{$0} <day_file_path> <night_file_path> <symlink_path>"
exit 1
end
DayNight.symlink(
latitude: ENV.fetch('LAT'), # Latitude of area in degrees North
longitude: ENV.fetch('LON'), # Longitude of area in degrees East
day_path: ARGV.fetch(0), # Path to day file
night_path: ARGV.fetch(1), # Path to night file
dest_path: ARGV.fetch(2) # Path to destination file (symbolic link)
)
I run it with cron every 5 minutes (output of crontab -l
):
*/5 * * * * LAT=37.9838 LON=23.7275 ruby ~/work/scripts/symlink-day-night.rb ~/src/tokyonight.nvim/extras/kitty/tokyonight_day.conf ~/src/tokyonight.nvim/extras/kitty/tokyonight_night.conf ~/.config/kitty/theme.conf
And in my kitty config I simply reference the symlinked theme:
font_family FiraCode
font_size 14
update_check_interval 0
include theme.conf