Angelos Orfanakos

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